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}