Skip to main content

kcode_k1_http_people_models/
lib.rs

1use std::{collections::HashSet, sync::Arc};
2
3use axum::{
4    Json, Router,
5    extract::{Extension, Path, State},
6    http::{HeaderValue, StatusCode, header::CACHE_CONTROL},
7    middleware,
8    response::{IntoResponse, Response},
9    routing::{get, put},
10};
11use kcode_k1_groups::{ALL_MODELS, GroupId, GroupRole, K1Groups, ModelId, TxId, UserId};
12use kcode_k1_http::Principal;
13use serde::Serialize;
14use tokio::task::spawn_blocking;
15
16#[derive(Clone, Debug, Eq, PartialEq)]
17pub struct LocalModel {
18    id: ModelId,
19    name: String,
20}
21
22impl LocalModel {
23    pub fn new(id: ModelId, name: String) -> Result<Self, String> {
24        validate_name(&name)?;
25        Ok(Self { id, name })
26    }
27
28    pub fn id(&self) -> ModelId {
29        self.id
30    }
31
32    pub fn name(&self) -> &str {
33        &self.name
34    }
35}
36
37#[derive(Clone)]
38struct AppState {
39    groups: Arc<K1Groups>,
40    models: Arc<[LocalModel]>,
41}
42
43pub fn authenticated_routes(
44    groups: Arc<K1Groups>,
45    models: Arc<[LocalModel]>,
46) -> Result<Router<()>, String> {
47    let mut ids = HashSet::with_capacity(models.len());
48    for model in models.iter() {
49        if !ids.insert(model.id()) {
50            return Err("duplicate local model id".to_owned());
51        }
52    }
53
54    Ok(Router::new()
55        .route("/people/models", get(catalog))
56        .route(
57            "/people/groups/{group_id}/models/{model_id}",
58            put(add).delete(remove),
59        )
60        .with_state(AppState { groups, models })
61        .layer(middleware::map_response(no_store)))
62}
63
64async fn catalog(
65    State(state): State<AppState>,
66    Extension(_principal): Extension<Principal>,
67) -> Json<CatalogDto> {
68    Json(catalog_body(&state.models))
69}
70
71async fn add(
72    State(state): State<AppState>,
73    Extension(principal): Extension<Principal>,
74    Path((group_id, model_id)): Path<(String, String)>,
75) -> Response {
76    let group = match parse_group(&group_id) {
77        Ok(group) => group,
78        Err(()) => return ApiError::invalid_group_id().into_response(),
79    };
80    let model = match parse_model(&model_id) {
81        Ok(model) => model,
82        Err(()) => return ApiError::invalid_model_id().into_response(),
83    };
84    if !catalog_contains(&state.models, model) {
85        return ApiError::model_not_found().into_response();
86    }
87
88    change(state, actor(&principal), group, model, true).await
89}
90
91async fn remove(
92    State(state): State<AppState>,
93    Extension(principal): Extension<Principal>,
94    Path((group_id, model_id)): Path<(String, String)>,
95) -> Response {
96    let group = match parse_group(&group_id) {
97        Ok(group) => group,
98        Err(()) => return ApiError::invalid_group_id().into_response(),
99    };
100    let model = match parse_model(&model_id) {
101        Ok(model) => model,
102        Err(()) => return ApiError::invalid_model_id().into_response(),
103    };
104
105    change(state, actor(&principal), group, model, false).await
106}
107
108async fn change(
109    state: AppState,
110    actor: UserId,
111    group: GroupId,
112    model: ModelId,
113    present: bool,
114) -> Response {
115    let fetched = match blocking_get(state.groups.clone(), group).await {
116        Ok(Some(group)) => group,
117        Ok(None) => return ApiError::group_not_found().into_response(),
118        Err(message) => {
119            return ApiError::groups_unavailable("group lookup", message).into_response();
120        }
121    };
122
123    let is_owner = fetched
124        .users()
125        .iter()
126        .any(|user| user.user_id() == actor && user.role() == GroupRole::Owner);
127    if !is_owner {
128        return ApiError::forbidden().into_response();
129    }
130
131    match blocking_set(state.groups, actor, group, model, present).await {
132        Ok(revision) => Json(MutationDto {
133            group_id: group_hex(revision.group_id()),
134            revision: tx_hex(revision.txid()),
135        })
136        .into_response(),
137        Err(message) => ApiError::groups_unavailable("group mutation", message).into_response(),
138    }
139}
140
141async fn blocking_get(
142    groups: Arc<K1Groups>,
143    group: GroupId,
144) -> Result<Option<kcode_k1_groups::Group>, String> {
145    spawn_blocking(move || groups.get(group))
146        .await
147        .map_err(|_| "blocking task failed".to_owned())?
148}
149
150async fn blocking_set(
151    groups: Arc<K1Groups>,
152    actor: UserId,
153    group: GroupId,
154    model: ModelId,
155    present: bool,
156) -> Result<kcode_k1_groups::GroupRevision, String> {
157    spawn_blocking(move || groups.set_model_membership(actor, group, model, present))
158        .await
159        .map_err(|_| "blocking task failed".to_owned())?
160}
161
162#[derive(Serialize)]
163struct CatalogDto {
164    all_models: AllModelsDto,
165    models: Vec<ModelDto>,
166}
167
168#[derive(Serialize)]
169struct AllModelsDto {
170    group_id: String,
171    name: &'static str,
172}
173
174#[derive(Serialize)]
175struct ModelDto {
176    model_id: String,
177    name: String,
178}
179
180#[derive(Serialize)]
181struct MutationDto {
182    group_id: String,
183    revision: String,
184}
185
186fn catalog_body(models: &[LocalModel]) -> CatalogDto {
187    CatalogDto {
188        all_models: AllModelsDto {
189            group_id: group_hex(ALL_MODELS),
190            name: "All models",
191        },
192        models: models.iter().map(ModelDto::from).collect(),
193    }
194}
195
196impl From<&LocalModel> for ModelDto {
197    fn from(model: &LocalModel) -> Self {
198        Self {
199            model_id: model_hex(model.id()),
200            name: model.name().to_owned(),
201        }
202    }
203}
204
205fn catalog_contains(models: &[LocalModel], model: ModelId) -> bool {
206    models.iter().any(|known| known.id() == model)
207}
208
209fn actor(principal: &Principal) -> UserId {
210    UserId::from_tx_id(TxId::from_bytes(*principal.user_id()))
211}
212
213fn parse_group(value: &str) -> Result<GroupId, ()> {
214    bytes(value, 12).map(|bytes| GroupId::new(TxId::from_bytes(bytes.try_into().unwrap())))
215}
216
217fn parse_model(value: &str) -> Result<ModelId, ()> {
218    bytes(value, 32).map(|bytes| ModelId::from_bytes(bytes.try_into().unwrap()))
219}
220
221fn bytes(value: &str, expected: usize) -> Result<Vec<u8>, ()> {
222    if value.len() != expected * 2
223        || !value
224            .bytes()
225            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
226    {
227        return Err(());
228    }
229
230    (0..expected)
231        .map(|index| u8::from_str_radix(&value[index * 2..index * 2 + 2], 16).map_err(|_| ()))
232        .collect()
233}
234
235fn group_hex(value: GroupId) -> String {
236    tx_hex(value.txid())
237}
238
239fn model_hex(value: ModelId) -> String {
240    hex(value.as_bytes())
241}
242
243fn tx_hex(value: TxId) -> String {
244    hex(value.as_bytes())
245}
246
247fn hex(bytes: &[u8]) -> String {
248    const HEX: &[u8; 16] = b"0123456789abcdef";
249    let mut output = String::with_capacity(bytes.len() * 2);
250
251    for byte in bytes {
252        output.push(HEX[(byte >> 4) as usize] as char);
253        output.push(HEX[(byte & 15) as usize] as char);
254    }
255
256    output
257}
258
259fn validate_name(name: &str) -> Result<(), String> {
260    if !(1..=128).contains(&name.len()) {
261        return Err("model name must be 1 through 128 UTF-8 bytes".to_owned());
262    }
263    if name.chars().any(char::is_control) {
264        return Err("model name must not contain control characters".to_owned());
265    }
266    if !name.chars().any(|character| !character.is_whitespace()) {
267        return Err("model name must contain a non-whitespace character".to_owned());
268    }
269
270    Ok(())
271}
272
273struct ApiError {
274    status: StatusCode,
275    error: &'static str,
276    message: String,
277}
278
279impl ApiError {
280    fn invalid_group_id() -> Self {
281        Self::new(
282            StatusCode::BAD_REQUEST,
283            "invalid_group_id",
284            "people_models: request validation: invalid group id",
285        )
286    }
287
288    fn invalid_model_id() -> Self {
289        Self::new(
290            StatusCode::BAD_REQUEST,
291            "invalid_model_id",
292            "people_models: request validation: invalid model id",
293        )
294    }
295
296    fn group_not_found() -> Self {
297        Self::new(
298            StatusCode::NOT_FOUND,
299            "group_not_found",
300            "people_models: group lookup: group not found",
301        )
302    }
303
304    fn model_not_found() -> Self {
305        Self::new(
306            StatusCode::NOT_FOUND,
307            "model_not_found",
308            "people_models: catalog lookup: model not found",
309        )
310    }
311
312    fn forbidden() -> Self {
313        Self::new(
314            StatusCode::FORBIDDEN,
315            "forbidden",
316            "people_models: authorization: owner role required",
317        )
318    }
319
320    fn groups_unavailable(phase: &str, dependency: String) -> Self {
321        Self::new(
322            StatusCode::SERVICE_UNAVAILABLE,
323            "groups_unavailable",
324            format!("people_models: {phase}: {dependency}"),
325        )
326    }
327
328    fn new(status: StatusCode, error: &'static str, message: impl Into<String>) -> Self {
329        Self {
330            status,
331            error,
332            message: message.into(),
333        }
334    }
335}
336
337impl IntoResponse for ApiError {
338    fn into_response(self) -> Response {
339        (
340            self.status,
341            Json(ErrorDto {
342                error: self.error,
343                message: self.message,
344            }),
345        )
346            .into_response()
347    }
348}
349
350#[derive(Serialize)]
351struct ErrorDto {
352    error: &'static str,
353    message: String,
354}
355
356async fn no_store(mut response: Response) -> Response {
357    response
358        .headers_mut()
359        .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
360    response
361}
362
363#[cfg(test)]
364mod tests {
365    use std::{path::Path, sync::Arc};
366
367    use axum::body::to_bytes;
368    use kcode_k1_groups::GroupName;
369    use kcode_k1_peering::K1Peering;
370    use kcode_k1_txn_ordering::K1TxnOrdering;
371    use serde_json::json;
372
373    use super::*;
374
375    fn user(value: u8) -> UserId {
376        UserId::from_tx_id(TxId::from_bytes([value; 12]))
377    }
378
379    fn name(value: &str) -> GroupName {
380        GroupName::new(value.to_owned()).unwrap()
381    }
382
383    fn groups(root: &Path) -> (Arc<K1TxnOrdering>, Arc<K1Peering>, Arc<K1Groups>) {
384        let ordering = Arc::new(K1TxnOrdering::open(&root.join("ordering")).unwrap());
385        let peering = Arc::new(K1Peering::open(&root.join("peering"), ordering.clone()).unwrap());
386        let groups = Arc::new(
387            K1Groups::open(&root.join("groups"), ordering.clone(), peering.clone()).unwrap(),
388        );
389        (ordering, peering, groups)
390    }
391
392    #[test]
393    fn catalog_dto_preserves_order_and_all_models() {
394        let first = LocalModel::new(ModelId::from_bytes([0x11; 32]), "First".to_owned()).unwrap();
395        let second = LocalModel::new(ModelId::from_bytes([0x22; 32]), "Second".to_owned()).unwrap();
396
397        assert_eq!(
398            serde_json::to_value(catalog_body(&[first, second])).unwrap(),
399            json!({
400                "all_models": {
401                    "group_id": "ff4b31475250000000000002",
402                    "name": "All models",
403                },
404                "models": [
405                    {"model_id": "11".repeat(32), "name": "First"},
406                    {"model_id": "22".repeat(32), "name": "Second"},
407                ],
408            })
409        );
410        assert_eq!(group_hex(ALL_MODELS), "ff4b31475250000000000002");
411    }
412
413    #[test]
414    fn names_ids_and_duplicate_catalogs_are_rejected() {
415        let model = LocalModel::new(ModelId::from_bytes([0xab; 32]), " Name ".to_owned()).unwrap();
416        assert_eq!(model.id(), ModelId::from_bytes([0xab; 32]));
417        assert_eq!(model.name(), " Name ");
418        assert!(LocalModel::new(model.id(), " \n".to_owned()).is_err());
419        assert!(LocalModel::new(model.id(), "\0valid".to_owned()).is_err());
420        assert!(parse_group(&"01".repeat(12)).is_ok());
421        assert!(parse_group(&"AB".repeat(12)).is_err());
422
423        let root = tempfile::tempdir().unwrap();
424        let (_ordering, _peering, groups) = groups(root.path());
425        assert!(authenticated_routes(groups, Arc::from(vec![model.clone(), model])).is_err());
426    }
427
428    #[tokio::test]
429    async fn errors_have_exact_error_body_keys_and_chain() {
430        let response = ApiError::invalid_model_id().into_response();
431        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
432
433        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
434        assert_eq!(
435            serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
436            json!({
437                "error": "invalid_model_id",
438                "message": "people_models: request validation: invalid model id",
439            })
440        );
441    }
442
443    #[test]
444    fn put_rejects_unknown_catalog_models() {
445        let catalogued = LocalModel::new(ModelId::from_bytes([1; 32]), "Known".to_owned()).unwrap();
446        assert!(catalog_contains(
447            &[catalogued],
448            ModelId::from_bytes([1; 32])
449        ));
450        assert!(!catalog_contains(&[], ModelId::from_bytes([2; 32])));
451
452        let error = ApiError::model_not_found();
453        assert_eq!(error.status, StatusCode::NOT_FOUND);
454        assert_eq!(error.error, "model_not_found");
455    }
456
457    #[tokio::test]
458    async fn owner_can_mutate_and_non_owner_cannot_delete_unregistered_model() {
459        let root = tempfile::tempdir().unwrap();
460        let (_ordering, _peering, groups) = groups(root.path());
461        let group = groups.create(user(1), name("owners")).unwrap().group_id();
462        let unregistered = ModelId::from_bytes([7; 32]);
463        let state = AppState {
464            groups: groups.clone(),
465            models: Arc::from([]),
466        };
467
468        assert_eq!(
469            change(state.clone(), user(1), group, unregistered, true)
470                .await
471                .status(),
472            StatusCode::OK
473        );
474        assert_eq!(
475            change(state.clone(), user(1), group, unregistered, false)
476                .await
477                .status(),
478            StatusCode::OK
479        );
480        assert_eq!(
481            change(state, user(2), group, unregistered, false)
482                .await
483                .status(),
484            StatusCode::FORBIDDEN
485        );
486    }
487}