use std::{collections::HashSet, sync::Arc};
use axum::{
Json, Router,
extract::{Extension, Path, State},
http::{HeaderValue, StatusCode, header::CACHE_CONTROL},
middleware,
response::{IntoResponse, Response},
routing::{get, put},
};
use kcode_k1_groups::{ALL_MODELS, GroupId, GroupRole, K1Groups, ModelId, TxId, UserId};
use kcode_k1_http::Principal;
use serde::Serialize;
use tokio::task::spawn_blocking;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct LocalModel {
id: ModelId,
name: String,
}
impl LocalModel {
pub fn new(id: ModelId, name: String) -> Result<Self, String> {
validate_name(&name)?;
Ok(Self { id, name })
}
pub fn id(&self) -> ModelId {
self.id
}
pub fn name(&self) -> &str {
&self.name
}
}
#[derive(Clone)]
struct AppState {
groups: Arc<K1Groups>,
models: Arc<[LocalModel]>,
}
pub fn authenticated_routes(
groups: Arc<K1Groups>,
models: Arc<[LocalModel]>,
) -> Result<Router<()>, String> {
let mut ids = HashSet::with_capacity(models.len());
for model in models.iter() {
if !ids.insert(model.id()) {
return Err("duplicate local model id".to_owned());
}
}
Ok(Router::new()
.route("/people/models", get(catalog))
.route(
"/people/groups/{group_id}/models/{model_id}",
put(add).delete(remove),
)
.with_state(AppState { groups, models })
.layer(middleware::map_response(no_store)))
}
async fn catalog(
State(state): State<AppState>,
Extension(_principal): Extension<Principal>,
) -> Json<CatalogDto> {
Json(catalog_body(&state.models))
}
async fn add(
State(state): State<AppState>,
Extension(principal): Extension<Principal>,
Path((group_id, model_id)): Path<(String, String)>,
) -> Response {
let group = match parse_group(&group_id) {
Ok(group) => group,
Err(()) => return ApiError::invalid_group_id().into_response(),
};
let model = match parse_model(&model_id) {
Ok(model) => model,
Err(()) => return ApiError::invalid_model_id().into_response(),
};
if !catalog_contains(&state.models, model) {
return ApiError::model_not_found().into_response();
}
change(state, actor(&principal), group, model, true).await
}
async fn remove(
State(state): State<AppState>,
Extension(principal): Extension<Principal>,
Path((group_id, model_id)): Path<(String, String)>,
) -> Response {
let group = match parse_group(&group_id) {
Ok(group) => group,
Err(()) => return ApiError::invalid_group_id().into_response(),
};
let model = match parse_model(&model_id) {
Ok(model) => model,
Err(()) => return ApiError::invalid_model_id().into_response(),
};
change(state, actor(&principal), group, model, false).await
}
async fn change(
state: AppState,
actor: UserId,
group: GroupId,
model: ModelId,
present: bool,
) -> Response {
let fetched = match blocking_get(state.groups.clone(), group).await {
Ok(Some(group)) => group,
Ok(None) => return ApiError::group_not_found().into_response(),
Err(message) => {
return ApiError::groups_unavailable("group lookup", message).into_response();
}
};
let is_owner = fetched
.users()
.iter()
.any(|user| user.user_id() == actor && user.role() == GroupRole::Owner);
if !is_owner {
return ApiError::forbidden().into_response();
}
match blocking_set(state.groups, actor, group, model, present).await {
Ok(revision) => Json(MutationDto {
group_id: group_hex(revision.group_id()),
revision: tx_hex(revision.txid()),
})
.into_response(),
Err(message) => ApiError::groups_unavailable("group mutation", message).into_response(),
}
}
async fn blocking_get(
groups: Arc<K1Groups>,
group: GroupId,
) -> Result<Option<kcode_k1_groups::Group>, String> {
spawn_blocking(move || groups.get(group))
.await
.map_err(|_| "blocking task failed".to_owned())?
}
async fn blocking_set(
groups: Arc<K1Groups>,
actor: UserId,
group: GroupId,
model: ModelId,
present: bool,
) -> Result<kcode_k1_groups::GroupRevision, String> {
spawn_blocking(move || groups.set_model_membership(actor, group, model, present))
.await
.map_err(|_| "blocking task failed".to_owned())?
}
#[derive(Serialize)]
struct CatalogDto {
all_models: AllModelsDto,
models: Vec<ModelDto>,
}
#[derive(Serialize)]
struct AllModelsDto {
group_id: String,
name: &'static str,
}
#[derive(Serialize)]
struct ModelDto {
model_id: String,
name: String,
}
#[derive(Serialize)]
struct MutationDto {
group_id: String,
revision: String,
}
fn catalog_body(models: &[LocalModel]) -> CatalogDto {
CatalogDto {
all_models: AllModelsDto {
group_id: group_hex(ALL_MODELS),
name: "All models",
},
models: models.iter().map(ModelDto::from).collect(),
}
}
impl From<&LocalModel> for ModelDto {
fn from(model: &LocalModel) -> Self {
Self {
model_id: model_hex(model.id()),
name: model.name().to_owned(),
}
}
}
fn catalog_contains(models: &[LocalModel], model: ModelId) -> bool {
models.iter().any(|known| known.id() == model)
}
fn actor(principal: &Principal) -> UserId {
UserId::from_tx_id(TxId::from_bytes(*principal.user_id()))
}
fn parse_group(value: &str) -> Result<GroupId, ()> {
bytes(value, 12).map(|bytes| GroupId::new(TxId::from_bytes(bytes.try_into().unwrap())))
}
fn parse_model(value: &str) -> Result<ModelId, ()> {
bytes(value, 32).map(|bytes| ModelId::from_bytes(bytes.try_into().unwrap()))
}
fn bytes(value: &str, expected: usize) -> Result<Vec<u8>, ()> {
if value.len() != expected * 2
|| !value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
return Err(());
}
(0..expected)
.map(|index| u8::from_str_radix(&value[index * 2..index * 2 + 2], 16).map_err(|_| ()))
.collect()
}
fn group_hex(value: GroupId) -> String {
tx_hex(value.txid())
}
fn model_hex(value: ModelId) -> String {
hex(value.as_bytes())
}
fn tx_hex(value: TxId) -> String {
hex(value.as_bytes())
}
fn hex(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut output = String::with_capacity(bytes.len() * 2);
for byte in bytes {
output.push(HEX[(byte >> 4) as usize] as char);
output.push(HEX[(byte & 15) as usize] as char);
}
output
}
fn validate_name(name: &str) -> Result<(), String> {
if !(1..=128).contains(&name.len()) {
return Err("model name must be 1 through 128 UTF-8 bytes".to_owned());
}
if name.chars().any(char::is_control) {
return Err("model name must not contain control characters".to_owned());
}
if !name.chars().any(|character| !character.is_whitespace()) {
return Err("model name must contain a non-whitespace character".to_owned());
}
Ok(())
}
struct ApiError {
status: StatusCode,
error: &'static str,
message: String,
}
impl ApiError {
fn invalid_group_id() -> Self {
Self::new(
StatusCode::BAD_REQUEST,
"invalid_group_id",
"people_models: request validation: invalid group id",
)
}
fn invalid_model_id() -> Self {
Self::new(
StatusCode::BAD_REQUEST,
"invalid_model_id",
"people_models: request validation: invalid model id",
)
}
fn group_not_found() -> Self {
Self::new(
StatusCode::NOT_FOUND,
"group_not_found",
"people_models: group lookup: group not found",
)
}
fn model_not_found() -> Self {
Self::new(
StatusCode::NOT_FOUND,
"model_not_found",
"people_models: catalog lookup: model not found",
)
}
fn forbidden() -> Self {
Self::new(
StatusCode::FORBIDDEN,
"forbidden",
"people_models: authorization: owner role required",
)
}
fn groups_unavailable(phase: &str, dependency: String) -> Self {
Self::new(
StatusCode::SERVICE_UNAVAILABLE,
"groups_unavailable",
format!("people_models: {phase}: {dependency}"),
)
}
fn new(status: StatusCode, error: &'static str, message: impl Into<String>) -> Self {
Self {
status,
error,
message: message.into(),
}
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(
self.status,
Json(ErrorDto {
error: self.error,
message: self.message,
}),
)
.into_response()
}
}
#[derive(Serialize)]
struct ErrorDto {
error: &'static str,
message: String,
}
async fn no_store(mut response: Response) -> Response {
response
.headers_mut()
.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
response
}
#[cfg(test)]
mod tests {
use std::{path::Path, sync::Arc};
use axum::body::to_bytes;
use kcode_k1_groups::GroupName;
use kcode_k1_peering::K1Peering;
use kcode_k1_txn_ordering::K1TxnOrdering;
use serde_json::json;
use super::*;
fn user(value: u8) -> UserId {
UserId::from_tx_id(TxId::from_bytes([value; 12]))
}
fn name(value: &str) -> GroupName {
GroupName::new(value.to_owned()).unwrap()
}
fn groups(root: &Path) -> (Arc<K1TxnOrdering>, Arc<K1Peering>, Arc<K1Groups>) {
let ordering = Arc::new(K1TxnOrdering::open(&root.join("ordering")).unwrap());
let peering = Arc::new(K1Peering::open(&root.join("peering"), ordering.clone()).unwrap());
let groups = Arc::new(
K1Groups::open(&root.join("groups"), ordering.clone(), peering.clone()).unwrap(),
);
(ordering, peering, groups)
}
#[test]
fn catalog_dto_preserves_order_and_all_models() {
let first = LocalModel::new(ModelId::from_bytes([0x11; 32]), "First".to_owned()).unwrap();
let second = LocalModel::new(ModelId::from_bytes([0x22; 32]), "Second".to_owned()).unwrap();
assert_eq!(
serde_json::to_value(catalog_body(&[first, second])).unwrap(),
json!({
"all_models": {
"group_id": "ff4b31475250000000000002",
"name": "All models",
},
"models": [
{"model_id": "11".repeat(32), "name": "First"},
{"model_id": "22".repeat(32), "name": "Second"},
],
})
);
assert_eq!(group_hex(ALL_MODELS), "ff4b31475250000000000002");
}
#[test]
fn names_ids_and_duplicate_catalogs_are_rejected() {
let model = LocalModel::new(ModelId::from_bytes([0xab; 32]), " Name ".to_owned()).unwrap();
assert_eq!(model.id(), ModelId::from_bytes([0xab; 32]));
assert_eq!(model.name(), " Name ");
assert!(LocalModel::new(model.id(), " \n".to_owned()).is_err());
assert!(LocalModel::new(model.id(), "\0valid".to_owned()).is_err());
assert!(parse_group(&"01".repeat(12)).is_ok());
assert!(parse_group(&"AB".repeat(12)).is_err());
let root = tempfile::tempdir().unwrap();
let (_ordering, _peering, groups) = groups(root.path());
assert!(authenticated_routes(groups, Arc::from(vec![model.clone(), model])).is_err());
}
#[tokio::test]
async fn errors_have_exact_error_body_keys_and_chain() {
let response = ApiError::invalid_model_id().into_response();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
json!({
"error": "invalid_model_id",
"message": "people_models: request validation: invalid model id",
})
);
}
#[test]
fn put_rejects_unknown_catalog_models() {
let catalogued = LocalModel::new(ModelId::from_bytes([1; 32]), "Known".to_owned()).unwrap();
assert!(catalog_contains(
&[catalogued],
ModelId::from_bytes([1; 32])
));
assert!(!catalog_contains(&[], ModelId::from_bytes([2; 32])));
let error = ApiError::model_not_found();
assert_eq!(error.status, StatusCode::NOT_FOUND);
assert_eq!(error.error, "model_not_found");
}
#[tokio::test]
async fn owner_can_mutate_and_non_owner_cannot_delete_unregistered_model() {
let root = tempfile::tempdir().unwrap();
let (_ordering, _peering, groups) = groups(root.path());
let group = groups.create(user(1), name("owners")).unwrap().group_id();
let unregistered = ModelId::from_bytes([7; 32]);
let state = AppState {
groups: groups.clone(),
models: Arc::from([]),
};
assert_eq!(
change(state.clone(), user(1), group, unregistered, true)
.await
.status(),
StatusCode::OK
);
assert_eq!(
change(state.clone(), user(1), group, unregistered, false)
.await
.status(),
StatusCode::OK
);
assert_eq!(
change(state, user(2), group, unregistered, false)
.await
.status(),
StatusCode::FORBIDDEN
);
}
}