Skip to main content

kcode_k1_http_audio/
lib.rs

1use std::sync::Arc;
2
3use axum::body::Bytes;
4use axum::extract::{Path, State};
5use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE};
6use axum::http::{HeaderMap, HeaderValue, StatusCode};
7use axum::response::{IntoResponse, Response};
8use axum::routing::{get, post, put};
9use axum::{Extension, Json, Router};
10use kcode_k1_access_full_audio::{
11    AccessId, FullAudioState, GroupId, K1AccessFullAudio, PersonId, SpeakerLabelV1, UserId,
12};
13use kcode_k1_access_profiles::{ProfileId, ProfileSelection, TxId};
14use kcode_k1_http::Principal;
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17
18pub fn authenticated_routes(audio: Arc<K1AccessFullAudio>) -> Router<()> {
19    Router::new()
20        .route("/audio", get(list_audio))
21        .route("/audio/profiles/{profile_id}", post(submit_audio))
22        .route("/audio/groups/{group_id}", get(list_group_audio))
23        .route("/audio/{access_id}", get(get_audio))
24        .route(
25            "/audio/{access_id}/fragments/{fragment_id}/audio",
26            get(get_fragment_audio),
27        )
28        .route(
29            "/audio/{access_id}/fragments/{fragment_id}/labels",
30            put(submit_labels),
31        )
32        .route(
33            "/audio/{access_id}/fragments/{fragment_id}/retry",
34            post(retry_fragment),
35        )
36        .route(
37            "/audio/{access_id}/fragments/{fragment_id}/discard",
38            post(discard_fragment),
39        )
40        .with_state(audio)
41}
42
43#[derive(Debug)]
44struct ApiError {
45    status: StatusCode,
46    code: &'static str,
47    message: String,
48}
49
50type ApiResult<T> = Result<T, ApiError>;
51
52#[derive(Serialize)]
53struct ErrorDto {
54    error: &'static str,
55    message: String,
56}
57
58impl IntoResponse for ApiError {
59    fn into_response(self) -> Response {
60        json_response(
61            self.status,
62            ErrorDto {
63                error: self.code,
64                message: self.message,
65            },
66        )
67    }
68}
69
70fn problem(status: StatusCode, code: &'static str, message: impl Into<String>) -> ApiError {
71    ApiError {
72        status,
73        code,
74        message: message.into(),
75    }
76}
77
78fn invalid_id(code: &'static str, subject: &str) -> ApiError {
79    problem(
80        StatusCode::BAD_REQUEST,
81        code,
82        format!("{subject} ID is not canonical lowercase hexadecimal"),
83    )
84}
85
86fn dependency_error(operation: &str, message: String) -> ApiError {
87    if message.contains("fragment does not belong to full audio") {
88        return problem(
89            StatusCode::NOT_FOUND,
90            "fragment_not_found",
91            "fragment is unavailable to the caller",
92        );
93    }
94    if message.contains("cannot access full audio") || message == "access denied" {
95        return problem(
96            StatusCode::NOT_FOUND,
97            "audio_not_found",
98            "audio is unavailable to the caller",
99        );
100    }
101    problem(
102        StatusCode::SERVICE_UNAVAILABLE,
103        "audio_unavailable",
104        format!("{operation}: {message}"),
105    )
106}
107
108fn json_response<T: Serialize>(status: StatusCode, value: T) -> Response {
109    let mut response = (status, Json(value)).into_response();
110    response
111        .headers_mut()
112        .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
113    response
114}
115
116fn no_content() -> Response {
117    let mut response = StatusCode::NO_CONTENT.into_response();
118    response
119        .headers_mut()
120        .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
121    response
122}
123
124async fn run<T, F>(operation: &'static str, task: F) -> ApiResult<T>
125where
126    T: Send + 'static,
127    F: FnOnce() -> Result<T, String> + Send + 'static,
128{
129    match tokio::task::spawn_blocking(task).await {
130        Ok(Ok(value)) => Ok(value),
131        Ok(Err(message)) => Err(dependency_error(operation, message)),
132        Err(_) => Err(problem(
133            StatusCode::SERVICE_UNAVAILABLE,
134            "audio_unavailable",
135            format!("{operation}: blocking task failed"),
136        )),
137    }
138}
139
140fn caller(principal: &Principal) -> UserId {
141    UserId::from_tx_id(TxId::from_bytes(*principal.user_id()))
142}
143
144fn parse_txid(value: &str, code: &'static str, subject: &str) -> ApiResult<TxId> {
145    if value.len() != 24 {
146        return Err(invalid_id(code, subject));
147    }
148    let mut output = [0; 12];
149    for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
150        let digit = |byte| match byte {
151            b'0'..=b'9' => Some(byte - b'0'),
152            b'a'..=b'f' => Some(byte - b'a' + 10),
153            _ => None,
154        };
155        output[index] = digit(pair[0])
156            .zip(digit(pair[1]))
157            .map(|(high, low)| high << 4 | low)
158            .ok_or_else(|| invalid_id(code, subject))?;
159    }
160    Ok(TxId::from_bytes(output))
161}
162
163fn parse_access_id(value: &str) -> ApiResult<AccessId> {
164    parse_txid(value, "invalid_access_id", "access").map(AccessId::new)
165}
166
167fn parse_fragment_id(value: &str) -> ApiResult<TxId> {
168    parse_txid(value, "invalid_fragment_id", "fragment")
169}
170
171fn parse_group_id(value: &str) -> ApiResult<GroupId> {
172    parse_txid(value, "invalid_group_id", "group").map(GroupId::new)
173}
174
175fn parse_profile_id(value: &str) -> ApiResult<ProfileId> {
176    parse_txid(value, "invalid_profile_id", "profile").map(ProfileId::new)
177}
178
179fn hex(bytes: &[u8; 12]) -> String {
180    const DIGITS: &[u8; 16] = b"0123456789abcdef";
181    let mut value = String::with_capacity(24);
182    for byte in bytes {
183        value.push(DIGITS[(byte >> 4) as usize] as char);
184        value.push(DIGITS[(byte & 15) as usize] as char);
185    }
186    value
187}
188
189fn access_hex(id: &AccessId) -> String {
190    hex(id.txid().as_bytes())
191}
192
193fn require_content_type(headers: &HeaderMap, expected: &str) -> ApiResult<()> {
194    let mut values = headers.get_all(CONTENT_TYPE).iter();
195    let valid = values
196        .next()
197        .and_then(|value| value.to_str().ok())
198        .is_some_and(|value| {
199            value
200                .split(';')
201                .next()
202                .is_some_and(|media| media.trim().eq_ignore_ascii_case(expected))
203        })
204        && values.next().is_none();
205    if valid {
206        Ok(())
207    } else {
208        Err(problem(
209            StatusCode::UNSUPPORTED_MEDIA_TYPE,
210            "unsupported_media_type",
211            "request content type is unsupported",
212        ))
213    }
214}
215
216#[derive(Serialize)]
217struct SubmittedDto {
218    access_id: String,
219    full_audio_id: String,
220}
221
222#[derive(Serialize)]
223struct FragmentDto {
224    fragment_id: String,
225    start_sample_48k: u64,
226    end_sample_48k: u64,
227    status: Value,
228}
229
230#[derive(Serialize)]
231struct StatusDto {
232    state: &'static str,
233    fragments: Vec<FragmentDto>,
234    final_transcript: Option<String>,
235}
236
237fn state_name(state: FullAudioState) -> &'static str {
238    match state {
239        FullAudioState::Processing => "processing",
240        FullAudioState::AwaitingLabels => "awaiting_labels",
241        FullAudioState::NeedsAttention => "needs_attention",
242        FullAudioState::Complete => "complete",
243    }
244}
245
246#[derive(Deserialize)]
247struct LabelsDto {
248    labels: Vec<LabelDto>,
249}
250
251#[derive(Deserialize)]
252struct LabelDto {
253    speaker: String,
254    person_id: Option<String>,
255}
256
257fn parse_labels(input: LabelsDto) -> ApiResult<Vec<SpeakerLabelV1>> {
258    input
259        .labels
260        .into_iter()
261        .map(|label| {
262            let person_id = label
263                .person_id
264                .map(|value| {
265                    parse_txid(&value, "invalid_person_id", "person").map(PersonId::from_tx_id)
266                })
267                .transpose()?;
268            Ok(SpeakerLabelV1 {
269                speaker: label.speaker.parse().map_err(|_| {
270                    problem(
271                        StatusCode::BAD_REQUEST,
272                        "invalid_labels",
273                        "speaker label is invalid",
274                    )
275                })?,
276                person_id,
277            })
278        })
279        .collect()
280}
281
282async fn submit_audio(
283    State(audio): State<Arc<K1AccessFullAudio>>,
284    Extension(principal): Extension<Principal>,
285    Path(value): Path<String>,
286    headers: HeaderMap,
287    body: Bytes,
288) -> ApiResult<Response> {
289    require_content_type(&headers, "application/octet-stream")?;
290    let profile = ProfileSelection::Saved(parse_profile_id(&value)?);
291    let user = caller(&principal);
292    let submitted = run("submit audio", move || {
293        audio.submit_for_user(user, profile, &body)
294    })
295    .await?;
296    Ok(json_response(
297        StatusCode::CREATED,
298        SubmittedDto {
299            access_id: access_hex(&submitted.access_id),
300            full_audio_id: hex(submitted.full_audio_id.as_bytes()),
301        },
302    ))
303}
304
305async fn list_audio(
306    State(audio): State<Arc<K1AccessFullAudio>>,
307    Extension(principal): Extension<Principal>,
308) -> ApiResult<Response> {
309    let user = caller(&principal);
310    let ids = run("list audio", move || audio.list_for_user(user)).await?;
311    Ok(json_response(
312        StatusCode::OK,
313        ids.iter().map(access_hex).collect::<Vec<_>>(),
314    ))
315}
316
317async fn list_group_audio(
318    State(audio): State<Arc<K1AccessFullAudio>>,
319    Extension(principal): Extension<Principal>,
320    Path(value): Path<String>,
321) -> ApiResult<Response> {
322    let group = parse_group_id(&value)?;
323    let user = caller(&principal);
324    let ids = run("list group audio", move || {
325        audio.list_group_for_user(user, group)
326    })
327    .await?;
328    Ok(json_response(
329        StatusCode::OK,
330        ids.iter().map(access_hex).collect::<Vec<_>>(),
331    ))
332}
333
334async fn get_audio(
335    State(audio): State<Arc<K1AccessFullAudio>>,
336    Extension(principal): Extension<Principal>,
337    Path(value): Path<String>,
338) -> ApiResult<Response> {
339    let access_id = parse_access_id(&value)?;
340    let user = caller(&principal);
341    let status = run("get audio status", move || {
342        audio.status_for_user(user, access_id)
343    })
344    .await?;
345    let state = state_name(status.state);
346    let mut fragments = Vec::with_capacity(status.fragments.len());
347    for fragment in status.fragments {
348        let serialized = serde_json::to_value(fragment.status).map_err(|_| {
349            problem(
350                StatusCode::SERVICE_UNAVAILABLE,
351                "audio_unavailable",
352                "serialize audio status: status serialization failed",
353            )
354        })?;
355        fragments.push(FragmentDto {
356            fragment_id: hex(fragment.fragment_id.as_bytes()),
357            start_sample_48k: fragment.start_sample_48k,
358            end_sample_48k: fragment.end_sample_48k,
359            status: serialized,
360        });
361    }
362    Ok(json_response(
363        StatusCode::OK,
364        StatusDto {
365            state,
366            fragments,
367            final_transcript: status.final_transcript,
368        },
369    ))
370}
371
372async fn get_fragment_audio(
373    State(audio): State<Arc<K1AccessFullAudio>>,
374    Extension(principal): Extension<Principal>,
375    Path((access_value, fragment_value)): Path<(String, String)>,
376) -> ApiResult<Response> {
377    let access_id = parse_access_id(&access_value)?;
378    let fragment_id = parse_fragment_id(&fragment_value)?;
379    let user = caller(&principal);
380    let bytes = run("get fragment audio", move || {
381        audio.fragment_audio_for_user(user, access_id, fragment_id)
382    })
383    .await?;
384    Ok((
385        [
386            (CONTENT_TYPE, HeaderValue::from_static("audio/ogg")),
387            (CACHE_CONTROL, HeaderValue::from_static("no-store")),
388        ],
389        bytes,
390    )
391        .into_response())
392}
393
394async fn submit_labels(
395    State(audio): State<Arc<K1AccessFullAudio>>,
396    Extension(principal): Extension<Principal>,
397    Path((access_value, fragment_value)): Path<(String, String)>,
398    headers: HeaderMap,
399    body: Bytes,
400) -> ApiResult<Response> {
401    require_content_type(&headers, "application/json")?;
402    let input: LabelsDto = serde_json::from_slice(&body).map_err(|_| {
403        problem(
404            StatusCode::BAD_REQUEST,
405            "invalid_json",
406            "request body is invalid",
407        )
408    })?;
409    let labels = parse_labels(input)?;
410    let access_id = parse_access_id(&access_value)?;
411    let fragment_id = parse_fragment_id(&fragment_value)?;
412    let user = caller(&principal);
413    run("submit fragment labels", move || {
414        audio.submit_labels_for_user(user, access_id, fragment_id, labels)
415    })
416    .await?;
417    Ok(no_content())
418}
419
420async fn retry_fragment(
421    State(audio): State<Arc<K1AccessFullAudio>>,
422    Extension(principal): Extension<Principal>,
423    Path((access_value, fragment_value)): Path<(String, String)>,
424) -> ApiResult<Response> {
425    let access_id = parse_access_id(&access_value)?;
426    let fragment_id = parse_fragment_id(&fragment_value)?;
427    let user = caller(&principal);
428    run("retry fragment", move || {
429        audio.retry_fragment_for_user(user, access_id, fragment_id)
430    })
431    .await?;
432    Ok(no_content())
433}
434
435async fn discard_fragment(
436    State(audio): State<Arc<K1AccessFullAudio>>,
437    Extension(principal): Extension<Principal>,
438    Path((access_value, fragment_value)): Path<(String, String)>,
439) -> ApiResult<Response> {
440    let access_id = parse_access_id(&access_value)?;
441    let fragment_id = parse_fragment_id(&fragment_value)?;
442    let user = caller(&principal);
443    run("discard fragment", move || {
444        audio.discard_fragment_for_user(user, access_id, fragment_id)
445    })
446    .await?;
447    Ok(no_content())
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453    use serde_json::json;
454
455    #[test]
456    fn canonical_ids_and_user_conversion_preserve_bytes() {
457        let id = "00112233445566778899aabb";
458        assert_eq!(hex(parse_fragment_id(id).unwrap().as_bytes()), id);
459        assert!(parse_fragment_id("00112233445566778899AABB").is_err());
460        assert!(parse_fragment_id("0011").is_err());
461        assert_eq!(
462            hex(caller_bytes([1; 12]).as_tx_id().as_bytes()),
463            "010101010101010101010101"
464        );
465    }
466
467    fn caller_bytes(bytes: [u8; 12]) -> UserId {
468        UserId::from_tx_id(TxId::from_bytes(bytes))
469    }
470
471    #[test]
472    fn labels_accept_known_and_unknown_people() {
473        let labels = parse_labels(LabelsDto {
474            labels: vec![
475                LabelDto {
476                    speaker: "Speaker 1".to_owned(),
477                    person_id: None,
478                },
479                LabelDto {
480                    speaker: "Speaker 2".to_owned(),
481                    person_id: Some("00112233445566778899aabb".to_owned()),
482                },
483            ],
484        })
485        .unwrap();
486        assert_eq!(labels.len(), 2);
487        assert!(labels[0].person_id.is_none());
488        assert_eq!(
489            hex(labels[1].person_id.unwrap().as_tx_id().as_bytes()),
490            "00112233445566778899aabb"
491        );
492        assert!(
493            parse_labels(LabelsDto {
494                labels: vec![LabelDto {
495                    speaker: "Unknown".to_owned(),
496                    person_id: None,
497                }],
498            })
499            .is_err()
500        );
501    }
502
503    #[test]
504    fn status_shape_and_hidden_errors_are_stable() {
505        let value = serde_json::to_value(StatusDto {
506            state: "processing",
507            fragments: Vec::new(),
508            final_transcript: None,
509        })
510        .unwrap();
511        assert_eq!(
512            value,
513            json!({
514                "state": "processing",
515                "fragments": [],
516                "final_transcript": null
517            })
518        );
519        let error = dependency_error(
520            "get audio status",
521            "principal cannot access full audio".to_owned(),
522        );
523        assert_eq!(error.status, StatusCode::NOT_FOUND);
524        assert_eq!(error.code, "audio_not_found");
525        assert!(!error.message.contains("principal"));
526    }
527
528    #[test]
529    fn content_types_and_public_route_assembler_are_exact() {
530        let mut headers = HeaderMap::new();
531        headers.insert(
532            CONTENT_TYPE,
533            HeaderValue::from_static("application/json; charset=utf-8"),
534        );
535        assert!(require_content_type(&headers, "application/json").is_ok());
536        assert!(require_content_type(&headers, "application/octet-stream").is_err());
537        let _ = authenticated_routes as fn(Arc<K1AccessFullAudio>) -> Router<()>;
538    }
539}