atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
a9b016e4b248ebfb4326a7881143856a1fe77252
1
ssh://brewery@pls.lobotomise.me:33/atagen/jelq.git
src/http.rsRust711 lines21.8 KB
1
use std::{
2
    collections::HashMap,
3
    convert::Infallible,
4
    fmt::Write as _,
5
    sync::{Arc, Mutex},
6
    time::Instant,
7
};
8
9
use axum::{
10
    Json, Router,
11
    body::{Body, Bytes},
12
    extract::{Path, Query, Request, State},
13
    http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri, Version, header},
14
    middleware::{self, Next},
15
    response::{IntoResponse, Response},
16
    routing::any,
17
};
18
use rand::Rng;
19
use serde_json::{Value, json};
20
21
use crate::{
22
    game::{
23
        EXCUSES, LUBRICANTS, Lube, MAX_STAMINA, MM_PER_INCH, PatchOp, Run, SUSPICION_LIMIT,
24
        Technique, shaft,
25
    },
26
    problem::{PROBLEM_BASE, Problem},
27
};
28
29
const REL_BASE: &str = "https://jelq.dev/rels/";
30
const COOKIE_ATTRIBUTES: &str = "Max-Age=31536000; Path=/; HttpOnly; SameSite=Strict";
31
const THERAPY: &str = "Your options are listed in the Allow header. There are more of them than you think. You could also try being honest. It is not in the Allow header because it is not a method.";
32
33
#[derive(Default)]
34
pub struct AppState {
35
    runs: Mutex<HashMap<String, Run>>,
36
}
37
38
impl AppState {
39
    pub fn insert(&self, run: Run) {
40
        self.runs.lock().unwrap().insert(run.id.clone(), run);
41
    }
42
}
43
44
type Shared = Arc<AppState>;
45
46
pub fn app() -> Router {
47
    router(Arc::default())
48
}
49
50
pub fn router(state: Shared) -> Router {
51
    Router::new()
52
        .route("/runs", any(runs))
53
        .route("/runs/{id}", any(run))
54
        .route("/runs/{id}/length", any(length))
55
        .route("/runs/{id}/foreskin", any(foreskin))
56
        .route("/runs/{id}/inventory", any(inventory))
57
        .route("/runs/{id}/inventory/{item}", any(item))
58
        .route("/runs/{id}/techniques", any(techniques))
59
        .route("/runs/{id}/drawer", any(drawer))
60
        .route("/runs/{id}/branches/{choice}", any(branch))
61
        .route("/runs/{id}/diagnosis", any(diagnosis))
62
        .route("/graveyard", any(graveyard))
63
        .route("/leaderboard", any(leaderboard))
64
        .fallback(not_found)
65
        .layer(middleware::from_fn(chrome))
66
        .with_state(state)
67
}
68
69
async fn chrome(request: Request, next: Next) -> Response {
70
    let mut response = next.run(request).await;
71
    let headers = response.headers_mut();
72
    headers.insert(header::SERVER, HeaderValue::from_static("jelqd/0.6.9"));
73
    headers.insert("x-powered-by", HeaderValue::from_static("blood"));
74
    headers.insert(
75
        header::CACHE_CONTROL,
76
        HeaderValue::from_static("no-store, private"),
77
    );
78
    response
79
}
80
81
fn header_str(headers: &HeaderMap, name: HeaderName) -> Option<&str> {
82
    headers.get(name).and_then(|v| v.to_str().ok())
83
}
84
85
fn cookie<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
86
    headers
87
        .get_all(header::COOKIE)
88
        .iter()
89
        .filter_map(|v| v.to_str().ok())
90
        .flat_map(|v| v.split(';'))
91
        .filter_map(|pair| pair.trim().split_once('='))
92
        .find(|(key, _)| *key == name)
93
        .map(|(_, value)| value)
94
}
95
96
fn unsupported(method: &Method, allow: &'static str) -> Response {
97
    if *method == Method::CONNECT {
98
        return StatusCode::NOT_IMPLEMENTED.into_response();
99
    }
100
    let known = matches!(
101
        method.as_str(),
102
        "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS" | "TRACE" | "JELQ"
103
    );
104
    if !known {
105
        return Problem::new(
106
            StatusCode::NOT_IMPLEMENTED,
107
            "not-implemented",
108
            format!("The server does not implement {method}."),
109
        )
110
        .into_response();
111
    }
112
    Problem::new(
113
        StatusCode::METHOD_NOT_ALLOWED,
114
        "method-not-allowed",
115
        format!("{method} is not supported here."),
116
    )
117
    .header(header::ALLOW, allow)
118
    .into_response()
119
}
120
121
fn options(allow: &'static str, patchable: bool) -> Response {
122
    let mut response = (
123
        StatusCode::OK,
124
        [(header::ALLOW, allow)],
125
        Json(json!({ "detail": THERAPY })),
126
    )
127
        .into_response();
128
    if patchable {
129
        response.headers_mut().insert(
130
            "accept-patch",
131
            HeaderValue::from_static("application/json-patch+json"),
132
        );
133
    }
134
    response
135
}
136
137
fn trace(method: &Method, uri: &Uri, version: Version, headers: &HeaderMap) -> Response {
138
    let mut message = format!("{method} {uri} {version:?}\r\n");
139
    for (name, value) in headers {
140
        let _ = write!(
141
            message,
142
            "{name}: {}\r\n",
143
            value.to_str().unwrap_or("<binary>")
144
        );
145
    }
146
    message.push_str("\r\n");
147
    (
148
        StatusCode::OK,
149
        [(header::CONTENT_TYPE, "message/http")],
150
        message,
151
    )
152
        .into_response()
153
}
154
155
fn links(run: &Run) -> String {
156
    let id = &run.id;
157
    let mut links = vec![format!("</runs/{id}>; rel=\"{REL_BASE}run\"")];
158
    for rel in [
159
        "length",
160
        "foreskin",
161
        "inventory",
162
        "techniques",
163
        "drawer",
164
        "diagnosis",
165
    ] {
166
        links.push(format!("</runs/{id}/{rel}>; rel=\"{REL_BASE}{rel}\""));
167
    }
168
    links.join(", ")
169
}
170
171
fn finish(run: &Run, mut response: Response) -> Response {
172
    if response.status() == StatusCode::NOT_MODIFIED {
173
        return response;
174
    }
175
    let mut stats = vec![
176
        (
177
            "x-self-reported-length",
178
            format!("{:.2}", run.self_reported()),
179
        ),
180
        ("x-girth", format!("{:.2}", run.girth)),
181
        (
182
            "x-foreskin",
183
            run.foreskin.map_or("gone".into(), |f| format!("{f:.2}")),
184
        ),
185
        ("x-eq", run.eq.to_string()),
186
        ("x-stamina", format!("{}/{MAX_STAMINA}", run.stamina)),
187
        (
188
            "x-suspicion",
189
            format!("{}/{SUSPICION_LIMIT}", run.suspicion),
190
        ),
191
        ("x-shame", run.total_shame().to_string()),
192
        ("link", links(run)),
193
    ];
194
    if run.observed {
195
        stats.push(("x-observed-by", "cat".into()));
196
    }
197
    if run.mom_present {
198
        stats.push(("via", "1.1 mom".into()));
199
        stats.push(("vary", "Mom".into()));
200
    }
201
    // RFC 9111 obsoleted Warning, which is why nobody heeds it.
202
    if run.eq > 70 && run.ended.is_none() {
203
        stats.push(("warning", "199 - \"it is turning purple\"".into()));
204
    }
205
    stats.push((
206
        "set-cookie",
207
        format!("shame={}; {COOKIE_ATTRIBUTES}", run.total_shame()),
208
    ));
209
    if run.ruler {
210
        stats.push(("set-cookie", format!("ruler=1; {COOKIE_ATTRIBUTES}")));
211
    }
212
    let headers = response.headers_mut();
213
    for (name, value) in stats {
214
        headers.append(name, HeaderValue::from_str(&value).expect("visible ascii"));
215
    }
216
    response
217
}
218
219
fn gone(run: &Run) -> Response {
220
    let ending = run.ended.as_ref().expect("only called on ended runs");
221
    Problem::new(StatusCode::GONE, "run-ended", "This run has ended.")
222
        .with("cause", format!("{PROBLEM_BASE}{}", ending.cause))
223
        .with("turn", ending.turn)
224
        .with("graveyard", "/graveyard")
225
        .into_response()
226
}
227
228
fn with_run(
229
    state: &AppState,
230
    id: &str,
231
    headers: &HeaderMap,
232
    mom_applies: bool,
233
    handle: impl FnOnce(&mut Run) -> Response,
234
) -> Response {
235
    let mut runs = state.runs.lock().unwrap();
236
    let Some(run) = runs.get_mut(id) else {
237
        return Problem::new(
238
            StatusCode::NOT_FOUND,
239
            "no-such-run",
240
            format!("There is no run {id}. There may never have been."),
241
        )
242
        .into_response();
243
    };
244
    if run.ended.is_some() {
245
        return gone(run);
246
    }
247
    run.observed = false;
248
    if mom_applies
249
        && run.mom_present
250
        && let Err(problem) = run.answer_door(header_str(headers, header::AUTHORIZATION))
251
    {
252
        return finish(run, problem.into_response());
253
    }
254
    let response = handle(run);
255
    finish(run, response)
256
}
257
258
fn respond<T: IntoResponse, E: IntoResponse>(result: Result<T, E>) -> Response {
259
    match result {
260
        Ok(ok) => ok.into_response(),
261
        Err(err) => err.into_response(),
262
    }
263
}
264
265
async fn runs(
266
    State(state): State<Shared>,
267
    method: Method,
268
    Query(query): Query<HashMap<String, String>>,
269
    headers: HeaderMap,
270
) -> Response {
271
    if method != Method::POST {
272
        return unsupported(&method, "POST");
273
    }
274
    let seed = match query.get("seed").map(|s| s.parse::<u64>()) {
275
        None => rand::rng().random(),
276
        Some(Ok(seed)) => seed,
277
        Some(Err(_)) => {
278
            return Problem::new(
279
                StatusCode::BAD_REQUEST,
280
                "bad-seed",
281
                "The seed must be an unsigned 64-bit integer.",
282
            )
283
            .into_response();
284
        }
285
    };
286
    let shame = cookie(&headers, "shame")
287
        .and_then(|s| s.parse().ok())
288
        .unwrap_or(0);
289
    let id = format!("{:08x}", rand::rng().random::<u32>());
290
    let run = Run::new(id.clone(), seed, shame);
291
    let response = (
292
        StatusCode::CREATED,
293
        [
294
            (header::LOCATION, format!("/runs/{id}")),
295
            (header::ETAG, run.etag()),
296
            (HeaderName::from_static("x-seed"), seed.to_string()),
297
        ],
298
        Json(run.view()),
299
    )
300
        .into_response();
301
    let response = finish(&run, response);
302
    state.insert(run);
303
    response
304
}
305
306
#[allow(clippy::too_many_arguments)]
307
async fn run(
308
    State(state): State<Shared>,
309
    Path(id): Path<String>,
310
    method: Method,
311
    uri: Uri,
312
    version: Version,
313
    Query(query): Query<HashMap<String, String>>,
314
    headers: HeaderMap,
315
    body: Bytes,
316
) -> Response {
317
    const ALLOW: &str = "GET, HEAD, JELQ, PUT, PATCH, DELETE, OPTIONS, TRACE";
318
    let now = Instant::now();
319
    with_run(&state, &id, &headers, true, |run| match method.as_str() {
320
        "GET" | "HEAD" => {
321
            let accept = header_str(&headers, header::ACCEPT).unwrap_or_default();
322
            if accept.contains("reassurance") {
323
                return Problem::new(
324
                    StatusCode::NOT_ACCEPTABLE,
325
                    "no-reassurance",
326
                    "The server does not produce reassurance.",
327
                )
328
                .into_response();
329
            }
330
            (
331
                StatusCode::OK,
332
                [(header::ETAG, run.etag())],
333
                Json(run.view()),
334
            )
335
                .into_response()
336
        }
337
        "JELQ" => jelq(run, &query, &headers, now),
338
        "PUT" => run.put().into_response(),
339
        "PATCH" => patch(run, &headers, &body),
340
        "DELETE" => {
341
            run.end("abandoned", "The run was abandoned.");
342
            StatusCode::NO_CONTENT.into_response()
343
        }
344
        "OPTIONS" => options(ALLOW, true),
345
        "TRACE" => {
346
            run.shame += 1;
347
            trace(&method, &uri, version, &headers)
348
        }
349
        _ => unsupported(&method, ALLOW),
350
    })
351
}
352
353
fn jelq(
354
    run: &mut Run,
355
    query: &HashMap<String, String>,
356
    headers: &HeaderMap,
357
    now: Instant,
358
) -> Response {
359
    let technique = match query.get("technique") {
360
        None => {
361
            return Problem::new(
362
                StatusCode::BAD_REQUEST,
363
                "technique-required",
364
                "No technique was specified.",
365
            )
366
            .with("techniques", format!("/runs/{}/techniques", run.id))
367
            .into_response();
368
        }
369
        Some(name) => match Technique::parse(name) {
370
            Some(t) => t,
371
            None => {
372
                return Problem::new(
373
                    StatusCode::BAD_REQUEST,
374
                    "unknown-technique",
375
                    format!("{name} is not a technique."),
376
                )
377
                .with("techniques", format!("/runs/{}/techniques", run.id))
378
                .into_response();
379
            }
380
        },
381
    };
382
    let intensity = match query.get("intensity").map(|i| i.parse::<i32>()) {
383
        None => 5,
384
        Some(Ok(i)) if (1..=10).contains(&i) => i,
385
        Some(_) => {
386
            return Problem::new(
387
                StatusCode::BAD_REQUEST,
388
                "intensity-out-of-range",
389
                "Intensity is measured from 1 to 10.",
390
            )
391
            .into_response();
392
        }
393
    };
394
    let lube = match Lube::from_content_type(header_str(headers, header::CONTENT_TYPE)) {
395
        Ok(lube) => lube,
396
        Err(problem) => return problem.into_response(),
397
    };
398
    respond(run.jelq(technique, intensity, lube, now))
399
}
400
401
fn patch(run: &mut Run, headers: &HeaderMap, body: &[u8]) -> Response {
402
    let content_type = header_str(headers, header::CONTENT_TYPE).unwrap_or_default();
403
    if !content_type.starts_with("application/json-patch+json") {
404
        return Problem::new(
405
            StatusCode::UNSUPPORTED_MEDIA_TYPE,
406
            "patch-format",
407
            "Patches are accepted as application/json-patch+json.",
408
        )
409
        .header(
410
            HeaderName::from_static("accept-patch"),
411
            "application/json-patch+json",
412
        )
413
        .into_response();
414
    }
415
    match serde_json::from_slice::<Vec<PatchOp>>(body) {
416
        Ok(ops) => respond(run.patch(&ops)),
417
        Err(_) => Problem::new(
418
            StatusCode::BAD_REQUEST,
419
            "malformed-patch",
420
            "The patch could not be parsed.",
421
        )
422
        .into_response(),
423
    }
424
}
425
426
async fn length(
427
    State(state): State<Shared>,
428
    Path(id): Path<String>,
429
    method: Method,
430
    headers: HeaderMap,
431
) -> Response {
432
    const ALLOW: &str = "GET, HEAD, OPTIONS";
433
    with_run(&state, &id, &headers, true, |run| match method.as_str() {
434
        "GET" | "HEAD" => {
435
            let content_type = [(header::CONTENT_TYPE, "text/plain; charset=us-ascii")];
436
            if run.ruler {
437
                let mm = (run.length * MM_PER_INCH).round() as usize;
438
                return (StatusCode::OK, content_type, shaft(mm)).into_response();
439
            }
440
            let mm = (run.self_reported() * MM_PER_INCH).round() as usize;
441
            let chunks: Vec<Result<Bytes, Infallible>> = shaft(mm)
442
                .into_bytes()
443
                .chunks(16)
444
                .map(|c| Ok(Bytes::copy_from_slice(c)))
445
                .collect();
446
            let body = Body::from_stream(futures_util::stream::iter(chunks));
447
            (StatusCode::OK, content_type, body).into_response()
448
        }
449
        "OPTIONS" => options(ALLOW, false),
450
        _ => unsupported(&method, ALLOW),
451
    })
452
}
453
454
async fn foreskin(
455
    State(state): State<Shared>,
456
    Path(id): Path<String>,
457
    method: Method,
458
    headers: HeaderMap,
459
) -> Response {
460
    const ALLOW: &str = "GET, HEAD, DELETE, OPTIONS";
461
    with_run(&state, &id, &headers, true, |run| match method.as_str() {
462
        "GET" | "HEAD" => match run.foreskin {
463
            Some(f) => (
464
                StatusCode::OK,
465
                [(header::ETAG, run.etag())],
466
                Json(json!({ "foreskin": (f * 100.0).round() / 100.0 })),
467
            )
468
                .into_response(),
469
            None => Problem::new(
470
                StatusCode::GONE,
471
                "foreskin-gone",
472
                "The foreskin is gone. It will not be returning.",
473
            )
474
            .into_response(),
475
        },
476
        "DELETE" => respond(run.delete_foreskin(header_str(&headers, header::IF_MATCH))),
477
        "OPTIONS" => options(ALLOW, false),
478
        _ => unsupported(&method, ALLOW),
479
    })
480
}
481
482
fn items(run: &Run) -> Vec<(&'static str, &'static str)> {
483
    let mut items = vec![(
484
        "warm-towel",
485
        "Reusable. Satisfies the warm-up precondition. Use technique=warmup.",
486
    )];
487
    if run.ice_packs > 0 {
488
        items.push((
489
            "ice-pack",
490
            "Lowers EQ by 40. Use technique=ice. There is one.",
491
        ));
492
    }
493
    if run.ruler {
494
        items.push((
495
            "ruler",
496
            "Measures honestly. The length resource now carries a Content-Length.",
497
        ));
498
    }
499
    if run.teapot {
500
        items.push(("teapot-spout", "Does nothing. Cannot be dropped."));
501
    }
502
    items
503
}
504
505
async fn inventory(
506
    State(state): State<Shared>,
507
    Path(id): Path<String>,
508
    method: Method,
509
    headers: HeaderMap,
510
) -> Response {
511
    const ALLOW: &str = "GET, HEAD, OPTIONS";
512
    with_run(&state, &id, &headers, false, |run| match method.as_str() {
513
        "GET" | "HEAD" => {
514
            let listed: Vec<Value> = items(run)
515
                .into_iter()
516
                .map(|(name, _)| json!({ "name": name, "href": format!("/runs/{}/inventory/{name}", run.id) }))
517
                .collect();
518
            Json(json!({ "items": listed, "excuses": EXCUSES })).into_response()
519
        }
520
        "OPTIONS" => options(ALLOW, false),
521
        _ => unsupported(&method, ALLOW),
522
    })
523
}
524
525
async fn item(
526
    State(state): State<Shared>,
527
    Path((id, name)): Path<(String, String)>,
528
    method: Method,
529
    headers: HeaderMap,
530
) -> Response {
531
    const ALLOW: &str = "GET, HEAD, DELETE, OPTIONS";
532
    with_run(&state, &id, &headers, false, |run| {
533
        let Some((name, description)) = items(run).into_iter().find(|(n, _)| *n == name) else {
534
            return Problem::new(
535
                StatusCode::NOT_FOUND,
536
                "no-such-item",
537
                "You do not have that.",
538
            )
539
            .into_response();
540
        };
541
        match method.as_str() {
542
            "GET" | "HEAD" => {
543
                Json(json!({ "name": name, "description": description })).into_response()
544
            }
545
            "DELETE" => Problem::new(
546
                StatusCode::FORBIDDEN,
547
                "cannot-be-dropped",
548
                "Items cannot be dropped.",
549
            )
550
            .into_response(),
551
            "OPTIONS" => options(ALLOW, false),
552
            _ => unsupported(&method, ALLOW),
553
        }
554
    })
555
}
556
557
async fn techniques(
558
    State(state): State<Shared>,
559
    Path(id): Path<String>,
560
    method: Method,
561
    headers: HeaderMap,
562
) -> Response {
563
    const ALLOW: &str = "GET, HEAD, OPTIONS";
564
    with_run(&state, &id, &headers, true, |run| match method.as_str() {
565
        "GET" | "HEAD" => {
566
            let listed: Vec<Value> = Technique::ALL
567
                .into_iter()
568
                .map(|t| t.describe(run.forum_read))
569
                .collect();
570
            Json(json!({
571
                "usage": format!("JELQ /runs/{}?technique=<name>&intensity=<1-10>", run.id),
572
                "lubricant": "Declared with Content-Type.",
573
                "lubricants": LUBRICANTS,
574
                "eq_band": [50, 70],
575
                "techniques": listed,
576
            }))
577
            .into_response()
578
        }
579
        "OPTIONS" => options(ALLOW, false),
580
        _ => unsupported(&method, ALLOW),
581
    })
582
}
583
584
async fn drawer(
585
    State(state): State<Shared>,
586
    Path(id): Path<String>,
587
    method: Method,
588
    headers: HeaderMap,
589
) -> Response {
590
    with_run(&state, &id, &headers, true, |run| match method.as_str() {
591
        "POST" => run.search_drawer().into_response(),
592
        "OPTIONS" => options("POST, OPTIONS", false),
593
        _ => unsupported(&method, "POST, OPTIONS"),
594
    })
595
}
596
597
async fn branch(
598
    State(state): State<Shared>,
599
    Path((id, choice)): Path<(String, String)>,
600
    method: Method,
601
    headers: HeaderMap,
602
) -> Response {
603
    with_run(&state, &id, &headers, true, |run| match method.as_str() {
604
        "POST" => respond(run.choose(&choice)),
605
        "OPTIONS" => options("POST, OPTIONS", false),
606
        _ => unsupported(&method, "POST, OPTIONS"),
607
    })
608
}
609
610
async fn diagnosis(
611
    State(state): State<Shared>,
612
    Path(id): Path<String>,
613
    method: Method,
614
    headers: HeaderMap,
615
) -> Response {
616
    const ALLOW: &str = "GET, HEAD, OPTIONS";
617
    with_run(&state, &id, &headers, false, |run| match method.as_str() {
618
        "GET" | "HEAD" => respond(run.diagnose(
619
            header_str(&headers, header::AUTHORIZATION),
620
            header_str(&headers, header::IF_NONE_MATCH),
621
        )),
622
        "OPTIONS" => options(ALLOW, false),
623
        _ => unsupported(&method, ALLOW),
624
    })
625
}
626
627
async fn graveyard(State(state): State<Shared>, method: Method) -> Response {
628
    if method != Method::GET && method != Method::HEAD {
629
        return unsupported(&method, "GET, HEAD");
630
    }
631
    let runs = state.runs.lock().unwrap();
632
    let dead: Vec<Value> = runs
633
        .values()
634
        .filter_map(|run| {
635
            let ending = run.ended.as_ref()?;
636
            Some(json!({
637
                "id": run.id,
638
                "cause": format!("{PROBLEM_BASE}{}", ending.cause),
639
                "detail": ending.detail,
640
                "turn": ending.turn,
641
            }))
642
        })
643
        .collect();
644
    Json(json!({ "runs": dead })).into_response()
645
}
646
647
async fn leaderboard(
648
    State(state): State<Shared>,
649
    method: Method,
650
    Query(query): Query<HashMap<String, String>>,
651
    headers: HeaderMap,
652
) -> Response {
653
    if method != Method::GET && method != Method::HEAD {
654
        return unsupported(&method, "GET, HEAD");
655
    }
656
    let truthful = match query.get("sort").map(String::as_str) {
657
        None | Some("self-reported-length") => false,
658
        Some("content-length") if cookie(&headers, "ruler") == Some("1") => true,
659
        Some("content-length") => {
660
            return Problem::new(
661
                StatusCode::FORBIDDEN,
662
                "ruler-required",
663
                "Sorting by the truth requires a Ruler.",
664
            )
665
            .into_response();
666
        }
667
        Some(_) => {
668
            return Problem::new(
669
                StatusCode::BAD_REQUEST,
670
                "unknown-sort",
671
                "The leaderboard sorts by self-reported-length or content-length.",
672
            )
673
            .into_response();
674
        }
675
    };
676
    let runs = state.runs.lock().unwrap();
677
    let mut entries: Vec<&Run> = runs.values().collect();
678
    entries.sort_by(|a, b| {
679
        let key = |r: &Run| {
680
            if truthful {
681
                r.length
682
            } else {
683
                r.self_reported()
684
            }
685
        };
686
        key(b).total_cmp(&key(a))
687
    });
688
    let entries: Vec<Value> = entries
689
        .into_iter()
690
        .map(|run| {
691
            let mut entry = json!({
692
                "id": run.id,
693
                "status": run.status(),
694
                "self_reported_length": (run.self_reported() * 100.0).round() / 100.0,
695
            });
696
            if truthful {
697
                entry["content_length"] = json!((run.length * MM_PER_INCH).round() as u64);
698
            }
699
            entry
700
        })
701
        .collect();
702
    Json(json!({ "entries": entries })).into_response()
703
}
704
705
async fn not_found() -> Problem {
706
    Problem::new(
707
        StatusCode::NOT_FOUND,
708
        "not-found",
709
        "The resource does not exist. This is not about you.",
710
    )
711
}