From e12b555f43a67cc38ec34b69e0775415d25efe1b Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 18 Sep 2026 17:17:02 +0900 Subject: [PATCH 1/8] Migrate repository routes to ServerApi --- Cargo.lock | 2 + crates/api-macros-impl/src/lib.rs | 122 ++++- crates/api-macros/src/axum.rs | 2 +- crates/api-macros/src/lib.rs | 17 +- crates/client/src/backend_api.rs | 4 + crates/client/src/backend_workspace.rs | 71 ++- crates/server-api/Cargo.toml | 4 +- crates/server-api/examples/export_openapi.rs | 16 + crates/server-api/src/lib.rs | 198 +++++++- crates/workspace-server/src/server.rs | 138 +++++- openapi/server-api.json | 495 +++++++++++++++++++ 11 files changed, 1020 insertions(+), 49 deletions(-) create mode 100644 crates/server-api/examples/export_openapi.rs create mode 100644 openapi/server-api.json diff --git a/Cargo.lock b/Cargo.lock index ae38b6690..3e63f2c58 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4157,8 +4157,10 @@ dependencies = [ "api-macros", "protocol", "runtime-api", + "schemars", "serde", "serde_json", + "sha2 0.11.0", "tokio", "ts-rs", "webauthn-rs-proto", diff --git a/crates/api-macros-impl/src/lib.rs b/crates/api-macros-impl/src/lib.rs index 79b46eaa7..52ba4beb2 100644 --- a/crates/api-macros-impl/src/lib.rs +++ b/crates/api-macros-impl/src/lib.rs @@ -104,7 +104,9 @@ struct RouteArgs { path: LitStr, operation_id: Option, status: Option, + alternate_status: Option, error_status: Option, + openapi: Option, } impl Parse for RouteArgs { @@ -114,7 +116,9 @@ impl Parse for RouteArgs { path, operation_id: None, status: None, + alternate_status: None, error_status: None, + openapi: None, }; while !input.is_empty() { @@ -132,12 +136,24 @@ impl Parse for RouteArgs { "operation_id", )?, "status" => set_once(&mut result.status, input.parse::()?, &key, "status")?, + "alternate_status" => set_once( + &mut result.alternate_status, + input.parse::()?, + &key, + "alternate_status", + )?, "error_status" => set_once( &mut result.error_status, input.parse::()?, &key, "error_status", )?, + "openapi" => set_once( + &mut result.openapi, + input.parse::()?, + &key, + "openapi", + )?, _ => { return Err(syn::Error::new( key.span(), @@ -167,6 +183,9 @@ enum Location { Query, Header, Body, + /// Trusted request-local context supplied by the server adapter. Extension + /// parameters are not part of the wire contract or generated client signature. + Extension, } impl Location { @@ -176,6 +195,7 @@ impl Location { Self::Query => quote!(Query), Self::Header => quote!(Header), Self::Body => quote!(Body), + Self::Extension => unreachable!("extensions are not wire parameters"), }; quote!(#api_crate::ParameterLocation::#variant) } @@ -201,7 +221,9 @@ struct Operation { error_body: Option, fallible: bool, response_status: u16, + alternate_status: Option, error_status: Option, + openapi_skip: bool, } struct ApiDefinition { @@ -392,6 +414,21 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result { "status must be a success status between 200 and 299", )); } + let alternate_status = parse_status(route.alternate_status.as_ref(), "alternate_status")?; + if let Some(status) = alternate_status + && (!(200..=299).contains(&status) || status == response_status) + { + return Err(syn::Error::new_spanned( + route.alternate_status, + "alternate_status must be a distinct success status between 200 and 299", + )); + } + if alternate_status.is_some() && response_body.is_none() { + return Err(syn::Error::new_spanned( + route.alternate_status, + "alternate_status requires a response body implementing HttpSuccess", + )); + } let error_status = parse_status(route.error_status.as_ref(), "error_status")?; if error_status.is_some() && error_body.is_none() { return Err(syn::Error::new_spanned( @@ -464,11 +501,20 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result { } (Location::Body, rust_name.clone()) } + Some((Location::Extension, wire_name)) => { + if wire_name.is_some() { + return Err(syn::Error::new( + ident.span(), + "#[extension] does not accept a wire name", + )); + } + (Location::Extension, rust_name.clone()) + } None if placeholders.contains(&rust_name) => (Location::Path, rust_name.clone()), None => { return Err(syn::Error::new( ident.span(), - "API arguments must use #[path], #[query], #[header], or #[body]; path arguments may omit #[path] when their name matches a placeholder", + "API arguments must use #[path], #[query], #[header], #[body], or #[extension]; path arguments may omit #[path] when their name matches a placeholder", )); } }; @@ -537,7 +583,9 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result { error_body, fallible, response_status, + alternate_status, error_status, + openapi_skip: route.openapi.as_ref().is_some_and(|value| !value.value), }) } @@ -588,6 +636,7 @@ fn take_location(attrs: &mut Vec) -> syn::Result Some(Location::Query), Some("header") => Some(Location::Header), Some("body") => Some(Location::Body), + Some("extension") => Some(Location::Extension), Some("stream") | Some("multipart") | Some("binary") => { return Err(syn::Error::new_spanned( attr, @@ -881,10 +930,13 @@ fn reqwest_adapter_tokens( let builder_ident = format_ident!("{}ClientBuilder", ident_text(trait_ident)); let methods = api.operations.iter().map(|operation| { let method_ident = &operation.method_ident; - let arguments = operation.parameters.iter().map(|parameter| { + let arguments = operation.parameters.iter().filter_map(|parameter| { + if matches!(parameter.location, Location::Extension) { + return None; + } let ident = ¶meter.rust_ident; let ty = ¶meter.ty; - quote!(#ident: #ty) + Some(quote!(#ident: #ty)) }); let path_segments = operation .path @@ -928,6 +980,10 @@ fn reqwest_adapter_tokens( .unwrap_or_else(|| quote!(::core::option::Option::None)); let method = reqwest_method_tokens(operation.method, api_crate); let response_status = operation.response_status; + let accepted_status = operation + .alternate_status + .map(|alternate| quote!(__actual == #response_status || __actual == #alternate)) + .unwrap_or_else(|| quote!(__actual == #response_status)); let response_type = operation .response_body .as_ref() @@ -985,7 +1041,7 @@ fn reqwest_adapter_tokens( .await .map_err(#api_crate::reqwest::ClientError::from)?; let __actual = __response.status().as_u16(); - if __actual == #response_status { + if #accepted_status { #success } #public_error @@ -1181,6 +1237,14 @@ fn axum_adapter_tokens( }; }) }); + let extension_extractors = operation.parameters.iter().filter_map(|parameter| { + if !matches!(parameter.location, Location::Extension) { + return None; + } + let ident = ¶meter.rust_ident; + let ty = ¶meter.ty; + Some(quote!(#api_crate::axum::framework::Extension(#ident): #api_crate::axum::framework::Extension<#ty>,)) + }); let body_extractor = operation.parameters.iter().find_map(|parameter| { if !matches!(parameter.location, Location::Body) { return None; @@ -1191,7 +1255,16 @@ fn axum_adapter_tokens( }); let call_arguments = operation.parameters.iter().map(|parameter| ¶meter.rust_ident); let response_status = operation.response_status; - let success = if operation.response_body.is_some() { + let success = if let Some(alternate_status) = operation.alternate_status { + quote!({ + let status = #api_crate::HttpSuccess::status_code(&value); + if status == #response_status || status == #alternate_status { + #api_crate::axum::json_response(#api_crate::axum::status(status), value) + } else { + #api_crate::axum::empty_response(#api_crate::axum::status(500)) + } + }) + } else if operation.response_body.is_some() { quote!(#api_crate::axum::json_response(#api_crate::axum::status(#response_status), value)) } else { quote!({ let _ = value; #api_crate::axum::empty_response(#api_crate::axum::status(#response_status)) }) @@ -1232,6 +1305,7 @@ fn axum_adapter_tokens( #path_extractor #(#query_extractors)* #header_extractor + #(#extension_extractors)* #body_extractor ) -> #api_crate::axum::framework::Response where @@ -1285,12 +1359,16 @@ fn openapi_adapter_tokens( to_snake_case(&api.item.ident), span = api.item.ident.span() ); - let operations = api.operations.iter().map(|operation| { + let operations = api + .operations + .iter() + .filter(|operation| !operation.openapi_skip) + .map(|operation| { let method = method_name(operation.method); let path = &operation.path; let operation_id = &operation.operation_id; let parameters = operation.parameters.iter().filter_map(|parameter| { - if matches!(parameter.location, Location::Body) { + if matches!(parameter.location, Location::Body | Location::Extension) { return None; } let ty = ¶meter.ty; @@ -1299,7 +1377,7 @@ fn openapi_adapter_tokens( Location::Path => "path", Location::Query => "query", Location::Header => "header", - Location::Body => unreachable!(), + Location::Body | Location::Extension => unreachable!(), }; Some(quote! { operation.parameter::<#ty>(#name, #location)?; @@ -1319,6 +1397,16 @@ fn openapi_adapter_tokens( operation.empty_response(#status, "Successful response")?; }, }; + let alternate_response = operation.alternate_status.map(|status| { + match operation.response_body.as_ref() { + Some(ty) => quote! { + operation.response::<#ty>(#status, "application/json", "Alternate successful response")?; + }, + None => quote! { + operation.empty_response(#status, "Alternate successful response")?; + }, + } + }); let error = operation .error_status .zip(operation.error_body.as_ref()) @@ -1334,6 +1422,7 @@ fn openapi_adapter_tokens( #(#parameters)* #request #response + #alternate_response #error operation.finish()?; } @@ -1383,7 +1472,13 @@ fn expand_api(api: ApiDefinition) -> syn::Result { .map(|operation| { let marker_ident = &operation.marker_ident; let metadata = metadata_tokens(operation, &api_crate); - let parameter_types = operation.parameters.iter().map(|parameter| ¶meter.ty); + let parameter_types = operation.parameters.iter().filter_map(|parameter| { + if matches!(parameter.location, Location::Extension) { + None + } else { + Some(¶meter.ty) + } + }); let request_type = operation .request_body .as_ref() @@ -1472,17 +1567,20 @@ fn metadata_tokens( }, None => quote!(::core::option::Option::None), }; - let parameters = operation.parameters.iter().map(|parameter| { + let parameters = operation.parameters.iter().filter_map(|parameter| { + if matches!(parameter.location, Location::Extension) { + return None; + } let rust_name = ¶meter.rust_name; let wire_name = ¶meter.wire_name; let location = parameter.location.tokens(api_crate); - quote! { + Some(quote! { #api_crate::ParameterMetadata { rust_name: #rust_name, wire_name: #wire_name, location: #location, } - } + }) }); quote! { diff --git a/crates/api-macros/src/axum.rs b/crates/api-macros/src/axum.rs index 5298324dc..73fbc7e3b 100644 --- a/crates/api-macros/src/axum.rs +++ b/crates/api-macros/src/axum.rs @@ -52,7 +52,7 @@ pub fn status(code: u16) -> StatusCode { pub mod framework { pub use axum::{ Json, Router, - extract::{Path, Query, State}, + extract::{Extension, Path, Query, State}, http::{HeaderMap, StatusCode}, response::Response, routing::{delete, get, head, options, patch, post, put}, diff --git a/crates/api-macros/src/lib.rs b/crates/api-macros/src/lib.rs index f6372171a..1a56b408e 100644 --- a/crates/api-macros/src/lib.rs +++ b/crates/api-macros/src/lib.rs @@ -38,13 +38,17 @@ //! `options`. The first argument is a path template. `operation_id` defaults to the Rust //! method name but should be set explicitly for contracts which must remain stable while Rust //! names evolve. Success status defaults to `200`, except an empty (`()`) response defaults to -//! `204`. A public error inferred from `Result` defaults to status `400`. +//! `204`. `alternate_status` opts a JSON response into [`HttpSuccess`] when two successful HTTP +//! outcomes share one schema. A public error inferred from `Result` defaults to status `400`. //! -//! Arguments are classified with `#[body]`, `#[query]`, `#[header]`, or `#[path]`. An -//! unannotated argument whose Rust name occurs in the route template is inferred as a path +//! Arguments are classified with `#[body]`, `#[query]`, `#[header]`, `#[path]`, or `#[extension]`. +//! Extension values are trusted server-local Axum context: generated clients and OpenAPI omit them. +//! An unannotated argument whose Rust name occurs in the route template is inferred as a path //! argument. Header attributes may carry a wire name, as in `#[header("x-request-id")]`. //! Exactly one JSON body is allowed. JSON request, response, and error bodies must be named //! Rust types; tuples, references, arrays, and other anonymous structural types are rejected. +//! `openapi = false` explicitly excludes an operation whose wire body cannot yet satisfy the +//! strict OpenAPI schema boundary; Reqwest and Axum adapters are still generated. //! //! # Generated names //! @@ -200,6 +204,13 @@ pub trait HttpError { fn status_code(&self) -> u16; } +/// Successful JSON response that selects one of an operation's declared success statuses. +/// +/// Operations opt in with `alternate_status`; ordinary one-status operations remain fully static. +pub trait HttpSuccess { + fn status_code(&self) -> u16; +} + /// Marker used when an operation has no request, response, or public error body. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum NoBody {} diff --git a/crates/client/src/backend_api.rs b/crates/client/src/backend_api.rs index 7e96d1896..5154a13f6 100644 --- a/crates/client/src/backend_api.rs +++ b/crates/client/src/backend_api.rs @@ -192,6 +192,10 @@ impl BackendApiClient { format!("Bearer {}", self.access_token.0) } + pub(crate) fn asynchronous_client(&self) -> reqwest::Client { + self.asynchronous.clone() + } + pub async fn require_success( &self, response: reqwest::Response, diff --git a/crates/client/src/backend_workspace.rs b/crates/client/src/backend_workspace.rs index 2673b0aba..c62120166 100644 --- a/crates/client/src/backend_workspace.rs +++ b/crates/client/src/backend_workspace.rs @@ -1,9 +1,8 @@ use crate::{BackendApiClient, BackendApiClientError}; use reqwest::Method; use server_api::{ - InitialRepositoryIntent, RepositoryListResponse, RepositorySummary, - WorkspaceCatalogListResponse, WorkspaceCreateRequest, WorkspaceCreateResponse, - WorkspaceSummary, + InitialRepositoryIntent, RepositorySummary, WorkspaceCatalogListResponse, + WorkspaceCreateRequest, WorkspaceCreateResponse, WorkspaceSummary, }; use std::fmt; @@ -32,6 +31,7 @@ pub enum BackendWorkspaceClientError { InvalidTarget(String), Api(BackendApiClientError), Http(reqwest::Error), + RepositoryApi(server_api::client_support::ClientError), } impl fmt::Display for BackendWorkspaceClientError { @@ -40,6 +40,7 @@ impl fmt::Display for BackendWorkspaceClientError { Self::InvalidTarget(message) => f.write_str(message), Self::Api(error) => write!(f, "{error}"), Self::Http(error) => write!(f, "{error}"), + Self::RepositoryApi(error) => write!(f, "{error}"), } } } @@ -58,6 +59,39 @@ impl From for BackendWorkspaceClientError { } } +impl From> + for BackendWorkspaceClientError +{ + fn from( + error: server_api::client_support::ClientError, + ) -> Self { + Self::RepositoryApi(error) + } +} + +#[derive(Clone, Debug)] +struct RepositoryBearerAuthorizer { + authorization: String, +} + +impl server_api::client_support::RequestAuthorizer for RepositoryBearerAuthorizer { + fn authorize( + &self, + _request: server_api::client_support::AuthorizerRequest<'_>, + ) -> Result { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::AUTHORIZATION, + self.authorization + .parse() + .map_err(|_| server_api::client_support::AuthorizationError::new())?, + ); + Ok(headers) + } +} + +const REPOSITORY_RESPONSE_LIMIT: usize = 16 * 1024 * 1024; + pub fn list_backend_workspaces_blocking( target: &BackendWorkspaceCatalogTarget, ) -> Result, BackendWorkspaceClientError> { @@ -75,6 +109,21 @@ pub fn list_backend_workspaces_blocking( pub fn list_backend_workspace_repositories_blocking( target: &BackendWorkspaceCatalogTarget, workspace_id: &str, +) -> Result, BackendWorkspaceClientError> { + let client = BackendApiClient::from_stored_token(&target.base_url)?; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| BackendWorkspaceClientError::InvalidTarget(error.to_string()))?; + runtime.block_on(list_backend_workspace_repositories_with_client( + &client, + workspace_id, + )) +} + +async fn list_backend_workspace_repositories_with_client( + backend: &BackendApiClient, + workspace_id: &str, ) -> Result, BackendWorkspaceClientError> { if workspace_id.is_empty() || workspace_id.len() > 200 @@ -86,12 +135,16 @@ pub fn list_backend_workspace_repositories_blocking( "Workspace id returned by Backend is invalid".to_string(), )); } - let client = BackendApiClient::from_stored_token(&target.base_url)?; - let response = client - .blocking_request(Method::GET, &format!("/api/w/{workspace_id}/repositories"))? - .send()?; - client.check_status(response.status())?; - let response = response.json::()?; + let client = server_api::ServerApiClient::builder(backend.origin().as_str()) + .map_err(|error| BackendWorkspaceClientError::InvalidTarget(error.to_string()))? + .client(backend.asynchronous_client()) + .authorizer(RepositoryBearerAuthorizer { + authorization: backend.authorization_header_value(), + }) + .response_body_limit(REPOSITORY_RESPONSE_LIMIT) + .build() + .map_err(|error| BackendWorkspaceClientError::InvalidTarget(error.to_string()))?; + let response = client.repository_list(workspace_id.to_owned()).await?; if response.workspace_id != workspace_id { return Err(BackendWorkspaceClientError::InvalidTarget( "Repository catalog response does not match the requested Workspace".to_string(), diff --git a/crates/server-api/Cargo.toml b/crates/server-api/Cargo.toml index 6a842a2f6..73e0889c2 100644 --- a/crates/server-api/Cargo.toml +++ b/crates/server-api/Cargo.toml @@ -10,10 +10,12 @@ default = [] typescript = ["dep:ts-rs", "protocol/typescript"] [dependencies] -api-macros = { workspace = true, features = ["reqwest", "axum"] } +api-macros = { workspace = true, features = ["reqwest", "axum", "openapi"] } protocol.workspace = true runtime-api.workspace = true +schemars.workspace = true serde = { workspace = true, features = ["derive"] } +sha2.workspace = true ts-rs = { version = "12.0.1", optional = true } webauthn-rs-proto = "0.5.5" workdir.workspace = true diff --git a/crates/server-api/examples/export_openapi.rs b/crates/server-api/examples/export_openapi.rs new file mode 100644 index 000000000..43721866f --- /dev/null +++ b/crates/server-api/examples/export_openapi.rs @@ -0,0 +1,16 @@ +use std::{env, fs, path::PathBuf}; + +fn main() -> Result<(), Box> { + let output = env::args_os() + .nth(1) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("openapi/server-api.json")); + let document = server_api::canonical_openapi_document()?; + let json = document.to_json()?; + if let Some(parent) = output.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&output, json)?; + println!("wrote {}", output.display()); + Ok(()) +} diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 6bca6ee32..affa7934d 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -8,7 +8,9 @@ use api_macros::api; pub use api_macros::axum as server_support; pub use api_macros::reqwest as client_support; pub use api_macros::{ApiContract, HttpMethod}; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use webauthn_rs_proto::{ CreationChallengeResponse, PublicKeyCredential, RegisterPublicKeyCredential, RequestChallengeResponse, @@ -17,6 +19,57 @@ use webauthn_rs_proto::{ pub type ServerApiError = runtime_api::RuntimeApiError; pub type ServerApiClientError = client_support::ClientError; +/// Error body shared by the existing repository routes and their generated adapters. +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +pub struct RepositoryApiError { + pub error: String, + pub message: String, + #[serde(default)] + pub diagnostics: Vec, + #[serde(skip, default = "default_repository_error_status")] + status: u16, +} + +const fn default_repository_error_status() -> u16 { + 400 +} + +impl RepositoryApiError { + pub fn new( + status: u16, + error: impl Into, + message: impl Into, + diagnostics: Vec, + ) -> Self { + Self { + error: error.into(), + message: message.into(), + diagnostics, + status, + } + } +} + +impl api_macros::HttpError for RepositoryApiError { + fn status_code(&self) -> u16 { + self.status + } +} + +macro_rules! impl_openapi_schema { + ($($ty:ty),+ $(,)?) => { + $(impl api_macros::openapi::OpenApiSchema for $ty {})+ + }; +} + +impl_openapi_schema!( + CreateWorkspaceRepositoryRequest, + CreateWorkspaceRepositoryResponse, + RepositoryApiError, + RepositoryListResponse, + RepositoryDetailResponse, +); + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct WorkspaceWorkerSessionResponse { pub subject: WorkspaceWorkerSubject, @@ -24,12 +77,13 @@ pub struct WorkspaceWorkerSessionResponse { pub observation: runtime_api::WorkerSessionAvailability, } -#[api(reqwest, axum)] +#[api(reqwest, axum, openapi)] pub trait ServerApi { #[get( "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/session", status = 200, - error_status = 400 + error_status = 400, + openapi = false )] async fn worker_session( &self, @@ -37,6 +91,59 @@ pub trait ServerApi { #[path] runtime_id: String, #[path] worker_id: String, ) -> Result; + + #[get("/api/w/{workspace_id}/repositories", status = 200, error_status = 400)] + async fn repository_list( + &self, + #[path] workspace_id: String, + ) -> Result; + + #[get( + "/api/w/{workspace_id}/repositories/{repository_key}", + status = 200, + error_status = 404 + )] + async fn repository_detail( + &self, + #[path] workspace_id: String, + #[path] repository_key: String, + ) -> Result; + + #[post( + "/api/w/{workspace_id}/repositories", + status = 201, + alternate_status = 200, + error_status = 400 + )] + async fn repository_create( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + #[body] request: CreateWorkspaceRepositoryRequest, + ) -> Result; +} + +/// Digest of the authoritative ServerApi Rust contract source used for artifact provenance. +pub fn canonical_openapi_source_digest() -> String { + let digest = Sha256::digest(include_bytes!("lib.rs")); + let mut encoded = String::with_capacity("sha256:".len() + digest.len() * 2); + encoded.push_str("sha256:"); + for byte in digest { + use std::fmt::Write as _; + write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail"); + } + encoded +} + +/// Build the deployment-independent canonical ServerApi OpenAPI document. +pub fn canonical_openapi_document() +-> Result { + let source_digest = canonical_openapi_source_digest(); + server_api_openapi(api_macros::openapi::OpenApiInfo { + title: "Yoi Server API", + version: env!("CARGO_PKG_VERSION"), + source_digest: &source_digest, + }) } /// Public browser-authentication configuration. @@ -394,7 +501,7 @@ pub fn validate_repository_key(value: &str) -> Result<(), RepositoryKeyError> { /// /// Local paths remain distinct from network Git transports so callers cannot /// accidentally treat an unmaterialized remote as a server-local filesystem path. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] pub enum RepositorySourceKind { @@ -451,7 +558,7 @@ impl<'de> Deserialize<'de> for RepositorySourceKind { } /// Stable Repository source identity stored by Workspace authority. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct RepositorySource { pub kind: RepositorySourceKind, @@ -464,7 +571,7 @@ pub struct RepositorySource { /// /// The Server parses and canonicalizes `source`; callers cannot assert a /// transport classification or supply credential material through this DTO. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct CreateWorkspaceRepositoryRequest { pub repository_key: String, @@ -473,14 +580,20 @@ pub struct CreateWorkspaceRepositoryRequest { pub default_ref: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] pub struct CreateWorkspaceRepositoryResponse { pub workspace_id: String, pub repository_key: String, pub replayed: bool, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +impl api_macros::HttpSuccess for CreateWorkspaceRepositoryResponse { + fn status_code(&self) -> u16 { + if self.replayed { 200 } else { 201 } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] pub enum RepositoryObservedStatus { @@ -1125,7 +1238,7 @@ pub enum WorkspaceProfileSourceProvenance { ProjectProfileSourceTree, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct RepositoryDiagnostic { @@ -1134,7 +1247,7 @@ pub struct RepositoryDiagnostic { pub message: String, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct GitRemoteSummary { @@ -1142,7 +1255,7 @@ pub struct GitRemoteSummary { pub fetch_url: String, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct GitRepositorySummary { @@ -1153,7 +1266,7 @@ pub struct GitRepositorySummary { pub remotes: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct RepositorySummary { @@ -1162,6 +1275,7 @@ pub struct RepositorySummary { pub provider: String, pub source: RepositorySource, #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub source_revision: u64, pub source_fingerprint: String, pub observed_status: RepositoryObservedStatus, @@ -1194,7 +1308,7 @@ pub struct GitCommitSummary { pub refs: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct RepositoryListResponse { @@ -1204,7 +1318,7 @@ pub struct RepositoryListResponse { pub diagnostics: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct RepositoryDetailResponse { @@ -1282,7 +1396,7 @@ pub struct RepositoryLogResponse { pub const TICKET_RELATIONS_QUERY_PATH: &str = "/tickets/relations/search"; pub const TICKET_ORCHESTRATION_PLANS_QUERY_PATH: &str = "/tickets/orchestration-plans/search"; -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] pub enum DiagnosticSeverity { @@ -1291,7 +1405,7 @@ pub enum DiagnosticSeverity { Error, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct Diagnostic { @@ -4985,6 +5099,60 @@ mod tests { } } +#[cfg(test)] +mod openapi_artifact_tests { + use super::*; + + #[test] + fn canonical_openapi_artifact_is_current() { + let generated = canonical_openapi_document() + .expect("canonical OpenAPI contract must be valid") + .to_json() + .expect("canonical OpenAPI contract must serialize"); + let checked_in = include_str!("../../../openapi/server-api.json"); + assert_eq!( + generated, checked_in, + "regenerate with `cargo run -p server-api --example export_openapi -- openapi/server-api.json`", + ); + } + + #[test] + fn repository_operations_are_in_the_canonical_contract() { + let document = canonical_openapi_document().expect("canonical OpenAPI contract must build"); + let value: serde_json::Value = + serde_json::from_str(&document.to_json().expect("document must serialize")) + .expect("document must be JSON"); + + assert_eq!(value["openapi"], "3.1.0"); + assert!( + value["paths"] + ["/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/session"] + .is_null() + ); + assert!( + value["info"]["x-yoi-source-digest"] + .as_str() + .is_some_and(|digest| digest.starts_with("sha256:")) + ); + assert_eq!( + value["paths"]["/api/w/{workspace_id}/repositories"]["get"]["responses"]["200"]["content"] + ["application/json"]["schema"]["$ref"], + "#/components/schemas/RepositoryListResponse" + ); + assert!( + value["paths"]["/api/w/{workspace_id}/repositories"]["post"]["responses"]["200"] + .is_object() + ); + assert!( + value["paths"]["/api/w/{workspace_id}/repositories"]["post"]["responses"]["201"] + .is_object() + ); + assert!(value["paths"]["/api/w/{workspace_id}/repositories/{repository_key}"] + ["get"]["responses"]["404"] + .is_object()); + } +} + #[cfg(all(test, feature = "typescript"))] mod typescript_tests { #[test] diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 3877d94d2..a18699bd1 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -3074,6 +3074,56 @@ impl server_api::ServerApi for WorkspaceApi { observation, }) } + + async fn repository_list( + &self, + workspace_id: String, + ) -> std::result::Result + { + scoped_list_repositories( + State(self.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn repository_detail( + &self, + workspace_id: String, + repository_key: String, + ) -> std::result::Result + { + scoped_repository_detail( + State(self.clone()), + AxumPath(ScopedRepositoryPath { + workspace_id, + repository_key, + }), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn repository_create( + &self, + actor: RequestActor, + workspace_id: String, + request: CreateWorkspaceRepositoryRequest, + ) -> std::result::Result + { + scoped_create_repository( + State(self.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + Extension(actor), + Json(request), + ) + .await + .map(|(_status, Json(response))| response) + .map_err(ApiError::into_repository_api_error) + } } fn build_inner_router(api: WorkspaceApi) -> Router { @@ -3399,15 +3449,7 @@ fn build_inner_router(api: WorkspaceApi) -> Router { delete(scoped_unlink_objective_ticket), ) .route("/api/repositories", get(list_repositories)) - .route( - "/api/w/{workspace_id}/repositories", - get(scoped_list_repositories).post(scoped_create_repository), - ) .route("/api/repositories/{repository_key}", get(repository_detail)) - .route( - "/api/w/{workspace_id}/repositories/{repository_key}", - get(scoped_repository_detail), - ) .route( "/api/w/{workspace_id}/repositories/{repository_key}/ssh-connection-test", post(scoped_probe_repository_ssh_connection) @@ -18781,6 +18823,47 @@ impl ApiError { fn with_diagnostics(error: Error, diagnostics: Vec) -> Self { Self { error, diagnostics } } + + fn into_repository_api_error(self) -> server_api::RepositoryApiError { + let status = match &self.error { + Error::WorkspacePermissionDenied(_) | Error::BrowserReopenConfirmationRequired => { + StatusCode::FORBIDDEN + } + Error::RepositoryConflict(_) => StatusCode::CONFLICT, + Error::InvalidInput(_) + | Error::WorkerSourceIdentity(_) + | Error::InvalidRuntimeIdentifier { .. } + | Error::ReservedWorkerName(_) => StatusCode::BAD_REQUEST, + Error::RuntimeOperationFailed { code, .. } if code == "repository_not_configured" => { + StatusCode::NOT_FOUND + } + Error::RuntimeOperationFailed { code, .. } + if code == "repository_provider_unsupported" => + { + StatusCode::BAD_REQUEST + } + Error::InvalidRecordId(_) + | Error::MissingFrontmatter(_) + | Error::UnknownRepository(_) + | Error::UnknownRuntime(_) + | Error::UnknownWorker { .. } + | Error::RuntimeBindingNotFound { .. } + | Error::UnknownHost(_) + | Error::WorkspaceIdMismatch => StatusCode::NOT_FOUND, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }; + let message = if status == StatusCode::INTERNAL_SERVER_ERROR { + "internal server error".to_owned() + } else { + self.error.to_string() + }; + server_api::RepositoryApiError::new( + status.as_u16(), + status.canonical_reason().unwrap_or("error"), + message, + working_directory_diagnostics(self.diagnostics), + ) + } } impl IntoResponse for ApiError { @@ -22846,6 +22929,45 @@ mod tests { .unwrap(); assert!(String::from_utf8_lossy(&listed_body).contains("documentation")); + let repository_detail_uri = format!("{repositories_uri}/documentation"); + let detailed = app + .clone() + .oneshot( + Request::builder() + .uri(repository_detail_uri) + .header( + axum::http::header::COOKIE, + "yoi_workspace_session=browser-session-auth", + ) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(detailed.status(), StatusCode::OK); + let missing = app + .clone() + .oneshot( + Request::builder() + .uri(format!("{repositories_uri}/missing")) + .header( + axum::http::header::COOKIE, + "yoi_workspace_session=browser-session-auth", + ) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let missing_status = missing.status(); + let missing_body = to_bytes(missing.into_body(), usize::MAX).await.unwrap(); + assert_eq!( + missing_status, + StatusCode::NOT_FOUND, + "{}", + String::from_utf8_lossy(&missing_body) + ); + let identity_uri = format!( "/api/w/{}/settings/signing-identity", workspace.workspace.workspace_id diff --git a/openapi/server-api.json b/openapi/server-api.json new file mode 100644 index 000000000..409cf986e --- /dev/null +++ b/openapi/server-api.json @@ -0,0 +1,495 @@ +{ + "components": { + "schemas": { + "CreateWorkspaceRepositoryRequest": { + "additionalProperties": false, + "description": "Browser/user intent for registering one Repository in a Workspace.\n\nThe Server parses and canonicalizes `source`; callers cannot assert a\ntransport classification or supply credential material through this DTO.", + "properties": { + "default_ref": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "repository_key": { + "type": "string" + }, + "source": { + "type": "string" + } + }, + "required": [ + "repository_key", + "source" + ], + "type": "object" + }, + "CreateWorkspaceRepositoryResponse": { + "properties": { + "replayed": { + "type": "boolean" + }, + "repository_key": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "repository_key", + "replayed" + ], + "type": "object" + }, + "Diagnostic": { + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "severity": { + "$ref": "#/components/schemas/DiagnosticSeverity" + } + }, + "required": [ + "code", + "severity", + "message" + ], + "type": "object" + }, + "DiagnosticSeverity": { + "enum": [ + "info", + "warning", + "error" + ], + "type": "string" + }, + "GitRemoteSummary": { + "additionalProperties": false, + "properties": { + "fetch_url": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name", + "fetch_url" + ], + "type": "object" + }, + "GitRepositorySummary": { + "additionalProperties": false, + "properties": { + "branch": { + "type": [ + "string", + "null" + ] + }, + "dirty": { + "type": "boolean" + }, + "head": { + "type": [ + "string", + "null" + ] + }, + "remotes": { + "items": { + "$ref": "#/components/schemas/GitRemoteSummary" + }, + "type": "array" + }, + "status": { + "type": "string" + } + }, + "required": [ + "status", + "dirty", + "remotes" + ], + "type": "object" + }, + "RepositoryApiError": { + "description": "Error body shared by the existing repository routes and their generated adapters.", + "properties": { + "diagnostics": { + "default": [], + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error", + "message" + ], + "type": "object" + }, + "RepositoryDetailResponse": { + "additionalProperties": false, + "properties": { + "item": { + "$ref": "#/components/schemas/RepositorySummary" + }, + "source": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "item", + "source" + ], + "type": "object" + }, + "RepositoryDiagnostic": { + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "severity": { + "type": "string" + } + }, + "required": [ + "severity", + "code", + "message" + ], + "type": "object" + }, + "RepositoryListResponse": { + "additionalProperties": false, + "properties": { + "diagnostics": { + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "items": { + "items": { + "$ref": "#/components/schemas/RepositorySummary" + }, + "type": "array" + }, + "source": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "items", + "source", + "diagnostics" + ], + "type": "object" + }, + "RepositoryObservedStatus": { + "enum": [ + "unverified", + "ready", + "invalid" + ], + "type": "string" + }, + "RepositorySource": { + "description": "Stable Repository source identity stored by Workspace authority.", + "properties": { + "kind": { + "$ref": "#/components/schemas/RepositorySourceKind" + }, + "uri": { + "description": "Canonical source representation. This is an absolute local path for\n`local_path`, and a normalized URI/remote specification otherwise.", + "type": "string" + } + }, + "required": [ + "kind", + "uri" + ], + "type": "object" + }, + "RepositorySourceKind": { + "description": "Provider-neutral classification of an authoritative Repository source.\n\nLocal paths remain distinct from network Git transports so callers cannot\naccidentally treat an unmaterialized remote as a server-local filesystem path.", + "oneOf": [ + { + "enum": [ + "local_path", + "file", + "ssh", + "https" + ], + "type": "string" + }, + { + "const": "invalid", + "description": "A legacy value that could not be classified during migration. It remains\ninspectable but every provider operation must fail closed. Historical\n`http` wire values decode into this non-executable classification.", + "type": "string" + } + ] + }, + "RepositorySummary": { + "additionalProperties": false, + "properties": { + "default_selector": { + "type": [ + "string", + "null" + ] + }, + "diagnostics": { + "items": { + "$ref": "#/components/schemas/RepositoryDiagnostic" + }, + "type": [ + "array", + "null" + ] + }, + "git": { + "anyOf": [ + { + "$ref": "#/components/schemas/GitRepositorySummary" + }, + { + "type": "null" + } + ] + }, + "kind": { + "type": "string" + }, + "observed_at": { + "type": [ + "string", + "null" + ] + }, + "observed_status": { + "$ref": "#/components/schemas/RepositoryObservedStatus" + }, + "provider": { + "type": "string" + }, + "record_authority": { + "type": "string" + }, + "repository_key": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/RepositorySource" + }, + "source_fingerprint": { + "type": "string" + }, + "source_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "repository_key", + "kind", + "provider", + "source", + "source_revision", + "source_fingerprint", + "observed_status", + "record_authority" + ], + "type": "object" + }, + "string": { + "type": "string" + } + } + }, + "info": { + "title": "Yoi Server API", + "version": "0.1.0", + "x-yoi-source-digest": "sha256:1a30fd87bc5d6e1a03acfb86c86630ad8d458e5c0db8b3d146459d5f2c376db6" + }, + "openapi": "3.1.0", + "paths": { + "/api/w/{workspace_id}/repositories": { + "get": { + "operationId": "repository_list", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryListResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + } + }, + "post": { + "operationId": "repository_create", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateWorkspaceRepositoryRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateWorkspaceRepositoryResponse" + } + } + }, + "description": "Alternate successful response" + }, + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateWorkspaceRepositoryResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + } + } + }, + "/api/w/{workspace_id}/repositories/{repository_key}": { + "get": { + "operationId": "repository_detail", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "repository_key", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryDetailResponse" + } + } + }, + "description": "Successful response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + } + } + } + } +} From 8ef7c8e85403d64396ca9bcb9f99cd04402845ba Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 18 Sep 2026 17:20:26 +0900 Subject: [PATCH 2/8] Update ServerApi metadata coverage --- crates/server-api/src/lib.rs | 10 +++++++--- openapi/server-api.json | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index affa7934d..e1c63a767 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -3759,10 +3759,14 @@ mod tests { #[test] fn worker_session_contract_and_flattened_availability_are_stable() { let operations = ServerApiMetadata::OPERATIONS; - assert_eq!(operations.len(), 1); - assert_eq!(operations[0].method, HttpMethod::Get); + assert_eq!(operations.len(), 4); + let operation = operations + .iter() + .find(|operation| operation.operation_id == "worker_session") + .expect("worker-session operation must remain in ServerApi metadata"); + assert_eq!(operation.method, HttpMethod::Get); assert_eq!( - operations[0].path, + operation.path, "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/session" ); diff --git a/openapi/server-api.json b/openapi/server-api.json index 409cf986e..b73bca48f 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -342,7 +342,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:1a30fd87bc5d6e1a03acfb86c86630ad8d458e5c0db8b3d146459d5f2c376db6" + "x-yoi-source-digest": "sha256:c7a6b25e1a071ab9a6831e6cd53daf51acdf8aaac20746b0b6db35c12428836f" }, "openapi": "3.1.0", "paths": { From 91f6589e1f16f1a43140433c6f46ebf4f4fd3114 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 18 Sep 2026 17:23:27 +0900 Subject: [PATCH 3/8] Cover generated repository client request --- crates/client/src/backend_workspace.rs | 32 ++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/client/src/backend_workspace.rs b/crates/client/src/backend_workspace.rs index c62120166..c61dde11a 100644 --- a/crates/client/src/backend_workspace.rs +++ b/crates/client/src/backend_workspace.rs @@ -223,6 +223,38 @@ mod tests { handle.join().unwrap(); } + #[tokio::test] + async fn repository_catalog_uses_generated_path_and_bearer_authorizer() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = vec![0; 4096]; + let read = stream.read(&mut request).unwrap(); + let request = String::from_utf8_lossy(&request[..read]).to_ascii_lowercase(); + assert!(request.starts_with("get /api/w/workspace-test/repositories ")); + assert!(request.contains("authorization: bearer repository-secret\r\n")); + let body = r#"{"workspace_id":"workspace-test","items":[],"source":"workspace","diagnostics":[]}"#; + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .unwrap(); + }); + let client = + BackendApiClient::from_access_token_for_test(&base_url, "repository-secret").unwrap(); + + assert!( + list_backend_workspace_repositories_with_client(&client, "workspace-test") + .await + .unwrap() + .is_empty() + ); + handle.join().unwrap(); + } + #[test] fn create_request_keeps_operation_key_for_exact_retry() { let request = CreateBackendWorkspaceRequest { From 82c191d637fa4a888ea24062a1efa925574e099d Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 18 Sep 2026 17:35:27 +0900 Subject: [PATCH 4/8] Route repository aliases through ServerApi --- crates/server-api/src/lib.rs | 13 +++- crates/workspace-server/src/server.rs | 93 ++++++++++++++++++++++++++- openapi/server-api.json | 68 +++++++++++++++++++- 3 files changed, 170 insertions(+), 4 deletions(-) diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index e1c63a767..b90eec39a 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -98,6 +98,9 @@ pub trait ServerApi { #[path] workspace_id: String, ) -> Result; + #[get("/api/repositories", status = 200, error_status = 400)] + async fn repository_list_alias(&self) -> Result; + #[get( "/api/w/{workspace_id}/repositories/{repository_key}", status = 200, @@ -109,6 +112,12 @@ pub trait ServerApi { #[path] repository_key: String, ) -> Result; + #[get("/api/repositories/{repository_key}", status = 200, error_status = 404)] + async fn repository_detail_alias( + &self, + #[path] repository_key: String, + ) -> Result; + #[post( "/api/w/{workspace_id}/repositories", status = 201, @@ -3759,7 +3768,7 @@ mod tests { #[test] fn worker_session_contract_and_flattened_availability_are_stable() { let operations = ServerApiMetadata::OPERATIONS; - assert_eq!(operations.len(), 4); + assert_eq!(operations.len(), 6); let operation = operations .iter() .find(|operation| operation.operation_id == "worker_session") @@ -5143,6 +5152,8 @@ mod openapi_artifact_tests { ["application/json"]["schema"]["$ref"], "#/components/schemas/RepositoryListResponse" ); + assert!(value["paths"]["/api/repositories"]["get"].is_object()); + assert!(value["paths"]["/api/repositories/{repository_key}"]["get"].is_object()); assert!( value["paths"]["/api/w/{workspace_id}/repositories"]["post"]["responses"]["200"] .is_object() diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index a18699bd1..5fe926370 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -3089,6 +3089,16 @@ impl server_api::ServerApi for WorkspaceApi { .map_err(ApiError::into_repository_api_error) } + async fn repository_list_alias( + &self, + ) -> std::result::Result + { + list_repositories(State(self.clone())) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + async fn repository_detail( &self, workspace_id: String, @@ -3107,6 +3117,17 @@ impl server_api::ServerApi for WorkspaceApi { .map_err(ApiError::into_repository_api_error) } + async fn repository_detail_alias( + &self, + repository_key: String, + ) -> std::result::Result + { + repository_detail(State(self.clone()), AxumPath(repository_key)) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + async fn repository_create( &self, actor: RequestActor, @@ -3448,8 +3469,6 @@ fn build_inner_router(api: WorkspaceApi) -> Router { "/api/w/{workspace_id}/objectives/{objective_id}/ticket-links/{ticket_id}", delete(scoped_unlink_objective_ticket), ) - .route("/api/repositories", get(list_repositories)) - .route("/api/repositories/{repository_key}", get(repository_detail)) .route( "/api/w/{workspace_id}/repositories/{repository_key}/ssh-connection-test", post(scoped_probe_repository_ssh_connection) @@ -19047,6 +19066,28 @@ mod tests { SqliteWorkspaceStore, UserRecord, WorkspaceRecord, WorkspaceRuntimeBinding, }; + #[derive(Clone)] + struct TestBearerAuthorizer(&'static str); + + impl server_api::client_support::RequestAuthorizer for TestBearerAuthorizer { + fn authorize( + &self, + _request: server_api::client_support::AuthorizerRequest<'_>, + ) -> std::result::Result< + axum::http::HeaderMap, + server_api::client_support::AuthorizationError, + > { + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + axum::http::header::AUTHORIZATION, + format!("Bearer {}", self.0) + .parse() + .map_err(|_| server_api::client_support::AuthorizationError::new())?, + ); + Ok(headers) + } + } + #[test] fn backend_resource_timeout_maps_to_gateway_timeout() { assert_eq!( @@ -22968,6 +23009,54 @@ mod tests { String::from_utf8_lossy(&missing_body) ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let api_base_url = format!("http://{}", listener.local_addr().unwrap()); + let network_app = app.clone(); + let server = tokio::spawn(async move { + axum::serve(listener, network_app).await.unwrap(); + }); + let generated = server_api::ServerApiClient::builder(&api_base_url) + .unwrap() + .authorizer(TestBearerAuthorizer("api-token-auth")) + .build() + .unwrap(); + let generated_workspace_id = workspace.workspace.workspace_id.clone(); + let generated_list = generated + .repository_list(generated_workspace_id.clone()) + .await + .unwrap(); + assert!( + generated_list + .items + .iter() + .any(|repository| repository.repository_key == "documentation") + ); + assert_eq!( + generated + .repository_detail(generated_workspace_id.clone(), "documentation".to_owned()) + .await + .unwrap() + .item + .repository_key, + "documentation" + ); + let generated_request = CreateWorkspaceRepositoryRequest { + repository_key: "generated-client".to_owned(), + source: "/srv/repos/generated-client".to_owned(), + default_ref: Some("main".to_owned()), + }; + let created = generated + .repository_create(generated_workspace_id.clone(), generated_request.clone()) + .await + .unwrap(); + assert!(!created.replayed); + let replayed = generated + .repository_create(generated_workspace_id, generated_request) + .await + .unwrap(); + assert!(replayed.replayed); + server.abort(); + let identity_uri = format!( "/api/w/{}/settings/signing-identity", workspace.workspace.workspace_id diff --git a/openapi/server-api.json b/openapi/server-api.json index b73bca48f..c970e99e0 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -342,10 +342,76 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:c7a6b25e1a071ab9a6831e6cd53daf51acdf8aaac20746b0b6db35c12428836f" + "x-yoi-source-digest": "sha256:317cc65b39674bd229abc364d2daea3e909215471da5828313b3cf3ee2142600" }, "openapi": "3.1.0", "paths": { + "/api/repositories": { + "get": { + "operationId": "repository_list_alias", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryListResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + } + } + }, + "/api/repositories/{repository_key}": { + "get": { + "operationId": "repository_detail_alias", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "repository_key", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryDetailResponse" + } + } + }, + "description": "Successful response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + } + } + }, "/api/w/{workspace_id}/repositories": { "get": { "operationId": "repository_list", From 103fee0ba43a031f18022f080db46ba45d8502bd Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 18 Sep 2026 18:34:29 +0900 Subject: [PATCH 5/8] Complete repository contract error coverage --- crates/api-macros-impl/src/lib.rs | 157 ++++++- crates/api-macros/src/axum.rs | 2 +- crates/api-macros/src/lib.rs | 11 +- crates/api-macros/src/openapi.rs | 62 ++- crates/client/src/backend_workspace.rs | 114 ++++- crates/server-api/examples/export_openapi.rs | 60 ++- crates/server-api/src/lib.rs | 121 +++++- crates/workspace-server/src/server.rs | 424 ++++++++++++------- openapi/server-api.json | 293 ++++++++++++- 9 files changed, 1032 insertions(+), 212 deletions(-) diff --git a/crates/api-macros-impl/src/lib.rs b/crates/api-macros-impl/src/lib.rs index 52ba4beb2..2395767ef 100644 --- a/crates/api-macros-impl/src/lib.rs +++ b/crates/api-macros-impl/src/lib.rs @@ -106,6 +106,10 @@ struct RouteArgs { status: Option, alternate_status: Option, error_status: Option, + additional_error_statuses: Vec, + bearer_auth: Option, + browser_auth: Option, + normalize_body_errors: Option, openapi: Option, } @@ -118,6 +122,10 @@ impl Parse for RouteArgs { status: None, alternate_status: None, error_status: None, + additional_error_statuses: Vec::new(), + bearer_auth: None, + browser_auth: None, + normalize_body_errors: None, openapi: None, }; @@ -148,6 +156,40 @@ impl Parse for RouteArgs { &key, "error_status", )?, + "additional_error_statuses" => { + if !result.additional_error_statuses.is_empty() { + return Err(syn::Error::new( + key.span(), + "duplicate `additional_error_statuses` route option", + )); + } + let content; + syn::bracketed!(content in input); + while !content.is_empty() { + result.additional_error_statuses.push(content.parse()?); + if !content.is_empty() { + content.parse::()?; + } + } + } + "bearer_auth" => set_once( + &mut result.bearer_auth, + input.parse::()?, + &key, + "bearer_auth", + )?, + "browser_auth" => set_once( + &mut result.browser_auth, + input.parse::()?, + &key, + "browser_auth", + )?, + "normalize_body_errors" => set_once( + &mut result.normalize_body_errors, + input.parse::()?, + &key, + "normalize_body_errors", + )?, "openapi" => set_once( &mut result.openapi, input.parse::()?, @@ -223,6 +265,10 @@ struct Operation { response_status: u16, alternate_status: Option, error_status: Option, + additional_error_statuses: Vec, + bearer_auth: bool, + browser_auth: bool, + normalize_body_errors: bool, openapi_skip: bool, } @@ -445,6 +491,34 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result { )); } let error_status = error_body.as_ref().map(|_| error_status.unwrap_or(400)); + let mut additional_error_statuses = Vec::new(); + for status in &route.additional_error_statuses { + let value = status.base10_parse::().map_err(|_| { + syn::Error::new( + status.span(), + "HTTP status must be an unsigned 16-bit integer", + ) + })?; + if !(400..=599).contains(&value) { + return Err(syn::Error::new( + status.span(), + "additional error statuses must be between 400 and 599", + )); + } + if error_status == Some(value) || additional_error_statuses.contains(&value) { + return Err(syn::Error::new( + status.span(), + "additional error statuses must be distinct", + )); + } + additional_error_statuses.push(value); + } + if !additional_error_statuses.is_empty() && error_body.is_none() { + return Err(syn::Error::new_spanned( + &route.path, + "additional_error_statuses require a public error response type", + )); + } if http_method == Method::Head && response_body.is_some() { return Err(syn::Error::new_spanned( @@ -585,6 +659,13 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result { response_status, alternate_status, error_status, + additional_error_statuses, + bearer_auth: route.bearer_auth.as_ref().is_some_and(|value| value.value), + browser_auth: route.browser_auth.as_ref().is_some_and(|value| value.value), + normalize_body_errors: route + .normalize_body_errors + .as_ref() + .is_some_and(|value| value.value), openapi_skip: route.openapi.as_ref().is_some_and(|value| !value.value), }) } @@ -1245,14 +1326,49 @@ fn axum_adapter_tokens( let ty = ¶meter.ty; Some(quote!(#api_crate::axum::framework::Extension(#ident): #api_crate::axum::framework::Extension<#ty>,)) }); - let body_extractor = operation.parameters.iter().find_map(|parameter| { - if !matches!(parameter.location, Location::Body) { - return None; - } - let ident = ¶meter.rust_ident; - let ty = ¶meter.ty; - Some(quote!(#api_crate::axum::framework::Json(#ident): #api_crate::axum::framework::Json<#ty>,)) - }); + let (body_extractor, body_rejection) = operation + .parameters + .iter() + .find(|parameter| matches!(parameter.location, Location::Body)) + .map(|parameter| { + let ident = ¶meter.rust_ident; + let ty = ¶meter.ty; + if !operation.normalize_body_errors { + return ( + Some(quote!(#api_crate::axum::framework::Json(#ident): #api_crate::axum::framework::Json<#ty>,)), + None, + ); + } + let error = operation + .error_body + .as_ref() + .expect("body operations require an error type"); + ( + Some(quote!( + __body: ::core::result::Result< + #api_crate::axum::framework::Json<#ty>, + #api_crate::axum::framework::JsonRejection, + >, + )), + Some(quote! { + let #ident = match __body { + ::core::result::Result::Ok(#api_crate::axum::framework::Json(value)) => value, + ::core::result::Result::Err(rejection) => { + let status = rejection.status().as_u16(); + let error = <#error as #api_crate::HttpRequestError>::from_request_rejection( + status, + rejection.body_text(), + ); + return #api_crate::axum::json_response( + #api_crate::axum::status(status), + error, + ); + } + }; + }), + ) + }) + .unwrap_or((None, None)); let call_arguments = operation.parameters.iter().map(|parameter| ¶meter.rust_ident); let response_status = operation.response_status; let success = if let Some(alternate_status) = operation.alternate_status { @@ -1312,6 +1428,7 @@ fn axum_adapter_tokens( S: super::#trait_ident + Send + Sync + 'static, { #(#header_parsers)* + #body_rejection #mapped } } @@ -1415,15 +1532,39 @@ fn openapi_adapter_tokens( operation.response::<#ty>(#status, "application/json", "Error response")?; } }); + let additional_errors = operation + .error_body + .as_ref() + .map(|ty| { + operation + .additional_error_statuses + .iter() + .map(|status| { + quote! { + operation.response::<#ty>(#status, "application/json", "Error response")?; + } + }) + .collect::>() + }) + .unwrap_or_default(); + let bearer_auth = operation.bearer_auth.then(|| { + quote! { operation.bearer_authentication(); } + }); + let browser_auth = operation.browser_auth.then(|| { + quote! { operation.browser_authentication(); } + }); quote! { { let mut operation = builder.operation(#method, #path, #operation_id)?; #(#parameters)* + #bearer_auth + #browser_auth #request #response #alternate_response #error + #(#additional_errors)* operation.finish()?; } } diff --git a/crates/api-macros/src/axum.rs b/crates/api-macros/src/axum.rs index 73fbc7e3b..aeaa3bfc7 100644 --- a/crates/api-macros/src/axum.rs +++ b/crates/api-macros/src/axum.rs @@ -52,7 +52,7 @@ pub fn status(code: u16) -> StatusCode { pub mod framework { pub use axum::{ Json, Router, - extract::{Extension, Path, Query, State}, + extract::{Extension, Path, Query, State, rejection::JsonRejection}, http::{HeaderMap, StatusCode}, response::Response, routing::{delete, get, head, options, patch, post, put}, diff --git a/crates/api-macros/src/lib.rs b/crates/api-macros/src/lib.rs index 1a56b408e..318bad6ef 100644 --- a/crates/api-macros/src/lib.rs +++ b/crates/api-macros/src/lib.rs @@ -39,7 +39,11 @@ //! method name but should be set explicitly for contracts which must remain stable while Rust //! names evolve. Success status defaults to `200`, except an empty (`()`) response defaults to //! `204`. `alternate_status` opts a JSON response into [`HttpSuccess`] when two successful HTTP -//! outcomes share one schema. A public error inferred from `Result` defaults to status `400`. +//! outcomes share one schema. A public error inferred from `Result` defaults to status `400`; +//! `additional_error_statuses` publishes the same typed error schema for other declared outcomes. +//! `bearer_auth = true` and `browser_auth = true` attach standard bearer and browser-session cookie +//! security schemes. Body operations may opt into typed Axum rejection normalization with +//! typed Axum rejection normalization with `normalize_body_errors = true` and [`HttpRequestError`]. //! //! Arguments are classified with `#[body]`, `#[query]`, `#[header]`, `#[path]`, or `#[extension]`. //! Extension values are trusted server-local Axum context: generated clients and OpenAPI omit them. @@ -211,6 +215,11 @@ pub trait HttpSuccess { fn status_code(&self) -> u16; } +/// Public error type capable of normalizing Axum request-body rejections. +pub trait HttpRequestError: HttpError { + fn from_request_rejection(status: u16, message: String) -> Self; +} + /// Marker used when an operation has no request, response, or public error body. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum NoBody {} diff --git a/crates/api-macros/src/openapi.rs b/crates/api-macros/src/openapi.rs index 6b6795003..4f5158289 100644 --- a/crates/api-macros/src/openapi.rs +++ b/crates/api-macros/src/openapi.rs @@ -131,6 +131,7 @@ pub struct OpenApiBuilder { schemas: BTreeMap, operation_ids: BTreeSet, uses_bearer_auth: bool, + uses_browser_auth: bool, } impl OpenApiBuilder { @@ -157,6 +158,7 @@ impl OpenApiBuilder { schemas: BTreeMap::new(), operation_ids: BTreeSet::new(), uses_bearer_auth: false, + uses_browser_auth: false, }) } @@ -199,6 +201,7 @@ impl OpenApiBuilder { request_body: None, responses: BTreeMap::new(), bearer_security_required: None, + browser_security_required: false, }) } @@ -214,15 +217,30 @@ impl OpenApiBuilder { let schemas = self.schemas.into_iter().collect::>(); let mut components = Map::new(); components.insert("schemas".to_owned(), Value::Object(schemas)); + let mut security_schemes = Map::new(); if self.uses_bearer_auth { + security_schemes.insert( + "bearerAuth".to_owned(), + json!({ + "type": "http", + "scheme": "bearer" + }), + ); + } + if self.uses_browser_auth { + security_schemes.insert( + "browserSession".to_owned(), + json!({ + "type": "apiKey", + "in": "cookie", + "name": "yoi_workspace_session" + }), + ); + } + if !security_schemes.is_empty() { components.insert( "securitySchemes".to_owned(), - json!({ - "bearerAuth": { - "type": "http", - "scheme": "bearer" - } - }), + Value::Object(security_schemes), ); } @@ -295,6 +313,7 @@ pub struct OpenApiOperation<'a> { request_body: Option, responses: BTreeMap, bearer_security_required: Option, + browser_security_required: bool, } impl OpenApiOperation<'_> { @@ -338,6 +357,16 @@ impl OpenApiOperation<'_> { Ok(()) } + pub fn bearer_authentication(&mut self) { + self.parent.uses_bearer_auth = true; + self.bearer_security_required = Some(true); + } + + pub fn browser_authentication(&mut self) { + self.parent.uses_browser_auth = true; + self.browser_security_required = true; + } + pub fn request_body( &mut self, content_type: &'static str, @@ -418,15 +447,18 @@ impl OpenApiOperation<'_> { if let Some(request_body) = self.request_body { operation.insert("requestBody".to_owned(), request_body); } - if let Some(required) = self.bearer_security_required { - operation.insert( - "security".to_owned(), - if required { - json!([{ "bearerAuth": [] }]) - } else { - json!([{}, { "bearerAuth": [] }]) - }, - ); + let mut security = Vec::new(); + if self.bearer_security_required == Some(false) { + security.push(json!({})); + } + if self.bearer_security_required.is_some() { + security.push(json!({ "bearerAuth": [] })); + } + if self.browser_security_required { + security.push(json!({ "browserSession": [] })); + } + if !security.is_empty() { + operation.insert("security".to_owned(), Value::Array(security)); } self.parent .paths diff --git a/crates/client/src/backend_workspace.rs b/crates/client/src/backend_workspace.rs index c61dde11a..edd6cc75a 100644 --- a/crates/client/src/backend_workspace.rs +++ b/crates/client/src/backend_workspace.rs @@ -69,6 +69,29 @@ impl From, +) -> BackendWorkspaceClientError { + let status = match &error { + server_api::client_support::ClientError::Public { status, .. } => Some(*status), + server_api::client_support::ClientError::Failure(_) => None, + }; + match status { + Some(reqwest::StatusCode::UNAUTHORIZED) => { + BackendWorkspaceClientError::Api(BackendApiClientError::Unauthorized { + origin: backend.origin().clone(), + }) + } + Some(reqwest::StatusCode::FORBIDDEN) => { + BackendWorkspaceClientError::Api(BackendApiClientError::Forbidden { + origin: backend.origin().clone(), + }) + } + _ => BackendWorkspaceClientError::RepositoryApi(error), + } +} + #[derive(Clone, Debug)] struct RepositoryBearerAuthorizer { authorization: String, @@ -144,7 +167,10 @@ async fn list_backend_workspace_repositories_with_client( .response_body_limit(REPOSITORY_RESPONSE_LIMIT) .build() .map_err(|error| BackendWorkspaceClientError::InvalidTarget(error.to_string()))?; - let response = client.repository_list(workspace_id.to_owned()).await?; + let response = client + .repository_list(workspace_id.to_owned()) + .await + .map_err(|error| repository_client_error(backend, error))?; if response.workspace_id != workspace_id { return Err(BackendWorkspaceClientError::InvalidTarget( "Repository catalog response does not match the requested Workspace".to_string(), @@ -255,6 +281,92 @@ mod tests { handle.join().unwrap(); } + #[tokio::test] + async fn repository_catalog_enforces_generated_response_limit() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = vec![0; 4096]; + let _ = stream.read(&mut request).unwrap(); + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + REPOSITORY_RESPONSE_LIMIT + 1, + ) + .unwrap(); + stream.flush().unwrap(); + let chunk = [b' '; 8192]; + let mut remaining = REPOSITORY_RESPONSE_LIMIT + 1; + while remaining > 0 { + let length = remaining.min(chunk.len()); + if stream.write_all(&chunk[..length]).is_err() { + break; + } + remaining -= length; + } + }); + let client = + BackendApiClient::from_access_token_for_test(&base_url, "repository-secret").unwrap(); + + let error = list_backend_workspace_repositories_with_client(&client, "workspace-test") + .await + .unwrap_err(); + assert!( + matches!( + &error, + BackendWorkspaceClientError::RepositoryApi( + server_api::client_support::ClientError::Failure( + server_api::client_support::ClientFailure::ResponseTooLarge { + limit: REPOSITORY_RESPONSE_LIMIT + } + ) + ) + ), + "unexpected error: {error:?}" + ); + handle.join().unwrap(); + } + + #[test] + fn repository_client_preserves_authentication_error_taxonomy() { + let client = + BackendApiClient::from_access_token_for_test("https://backend.example.test", "token") + .unwrap(); + let unauthorized = repository_client_error( + &client, + server_api::client_support::ClientError::public( + reqwest::StatusCode::UNAUTHORIZED, + server_api::RepositoryApiError::new( + 401, + "Unauthorized", + "authentication required", + Vec::new(), + ), + ), + ); + assert!(matches!( + unauthorized, + BackendWorkspaceClientError::Api(BackendApiClientError::Unauthorized { .. }) + )); + let forbidden = repository_client_error( + &client, + server_api::client_support::ClientError::public( + reqwest::StatusCode::FORBIDDEN, + server_api::RepositoryApiError::new( + 403, + "Forbidden", + "repository access denied", + Vec::new(), + ), + ), + ); + assert!(matches!( + forbidden, + BackendWorkspaceClientError::Api(BackendApiClientError::Forbidden { .. }) + )); + } + #[test] fn create_request_keeps_operation_key_for_exact_retry() { let request = CreateBackendWorkspaceRequest { diff --git a/crates/server-api/examples/export_openapi.rs b/crates/server-api/examples/export_openapi.rs index 43721866f..714c4c1d4 100644 --- a/crates/server-api/examples/export_openapi.rs +++ b/crates/server-api/examples/export_openapi.rs @@ -1,16 +1,54 @@ -use std::{env, fs, path::PathBuf}; +use std::{env, fs, io, path::PathBuf}; + +const DEFAULT_OUTPUT: &str = "openapi/server-api.json"; fn main() -> Result<(), Box> { - let output = env::args_os() - .nth(1) - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("openapi/server-api.json")); - let document = server_api::canonical_openapi_document()?; - let json = document.to_json()?; - if let Some(parent) = output.parent() { - fs::create_dir_all(parent)?; + let mut arguments = env::args_os().skip(1); + let first = arguments.next(); + let (mode, output) = match first.as_deref().and_then(|value| value.to_str()) { + Some("--check") => ("check", arguments.next().map(PathBuf::from)), + Some("--stdout") => ("stdout", None), + Some(_) => ("write", first.map(PathBuf::from)), + None => ("write", None), + }; + if arguments.next().is_some() { + return Err("usage: export_openapi [--check [PATH] | --stdout | PATH]".into()); + } + + let output = output.unwrap_or_else(|| PathBuf::from(DEFAULT_OUTPUT)); + let json = server_api::canonical_openapi_document()?.to_json()?; + match mode { + "stdout" => print!("{json}"), + "check" => { + let checked_in = fs::read_to_string(&output)?; + if checked_in != json { + return Err(format!( + "{} is stale; regenerate with `cargo run -p server-api --example export_openapi -- {}`", + output.display(), + output.display() + ) + .into()); + } + println!("{} is current", output.display()); + } + "write" => { + if let Some(parent) = output.parent() { + fs::create_dir_all(parent)?; + } + let mut temporary = output.clone(); + let extension = output + .extension() + .and_then(|value| value.to_str()) + .map_or_else(|| "tmp".to_owned(), |value| format!("{value}.tmp")); + temporary.set_extension(extension); + fs::write(&temporary, json)?; + fs::rename(&temporary, &output).or_else(|error| { + let _ = fs::remove_file(&temporary); + Err::<(), io::Error>(error) + })?; + println!("wrote {}", output.display()); + } + _ => unreachable!(), } - fs::write(&output, json)?; - println!("wrote {}", output.display()); Ok(()) } diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index b90eec39a..8b241e663 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -56,6 +56,18 @@ impl api_macros::HttpError for RepositoryApiError { } } +impl api_macros::HttpRequestError for RepositoryApiError { + fn from_request_rejection(status: u16, message: String) -> Self { + let reason = match status { + 413 => "Payload Too Large", + 415 => "Unsupported Media Type", + 422 => "Unprocessable Entity", + _ => "Bad Request", + }; + Self::new(status, reason, message, Vec::new()) + } +} + macro_rules! impl_openapi_schema { ($($ty:ty),+ $(,)?) => { $(impl api_macros::openapi::OpenApiSchema for $ty {})+ @@ -92,19 +104,36 @@ pub trait ServerApi { #[path] worker_id: String, ) -> Result; - #[get("/api/w/{workspace_id}/repositories", status = 200, error_status = 400)] + #[get( + "/api/w/{workspace_id}/repositories", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + bearer_auth = true, + browser_auth = true + )] async fn repository_list( &self, #[path] workspace_id: String, ) -> Result; - #[get("/api/repositories", status = 200, error_status = 400)] + #[get( + "/api/repositories", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + bearer_auth = true, + browser_auth = true + )] async fn repository_list_alias(&self) -> Result; #[get( "/api/w/{workspace_id}/repositories/{repository_key}", status = 200, - error_status = 404 + error_status = 404, + additional_error_statuses = [400, 401, 403, 500], + bearer_auth = true, + browser_auth = true )] async fn repository_detail( &self, @@ -112,7 +141,14 @@ pub trait ServerApi { #[path] repository_key: String, ) -> Result; - #[get("/api/repositories/{repository_key}", status = 200, error_status = 404)] + #[get( + "/api/repositories/{repository_key}", + status = 200, + error_status = 404, + additional_error_statuses = [400, 401, 403, 500], + bearer_auth = true, + browser_auth = true + )] async fn repository_detail_alias( &self, #[path] repository_key: String, @@ -122,7 +158,11 @@ pub trait ServerApi { "/api/w/{workspace_id}/repositories", status = 201, alternate_status = 200, - error_status = 400 + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 413, 422, 500], + bearer_auth = true, + browser_auth = true, + normalize_body_errors = true )] async fn repository_create( &self, @@ -132,9 +172,19 @@ pub trait ServerApi { ) -> Result; } -/// Digest of the authoritative ServerApi Rust contract source used for artifact provenance. +/// Digest of every authoritative input used to derive the canonical OpenAPI artifact. pub fn canonical_openapi_source_digest() -> String { - let digest = Sha256::digest(include_bytes!("lib.rs")); + let mut hasher = Sha256::new(); + for source in [ + include_bytes!("lib.rs").as_slice(), + include_bytes!("../../api-macros-impl/src/lib.rs").as_slice(), + include_bytes!("../../api-macros/src/lib.rs").as_slice(), + include_bytes!("../../api-macros/src/openapi.rs").as_slice(), + ] { + hasher.update((source.len() as u64).to_le_bytes()); + hasher.update(source); + } + let digest = hasher.finalize(); let mut encoded = String::with_capacity("sha256:".len() + digest.len() * 2); encoded.push_str("sha256:"); for byte in digest { @@ -5154,13 +5204,58 @@ mod openapi_artifact_tests { ); assert!(value["paths"]["/api/repositories"]["get"].is_object()); assert!(value["paths"]["/api/repositories/{repository_key}"]["get"].is_object()); - assert!( - value["paths"]["/api/w/{workspace_id}/repositories"]["post"]["responses"]["200"] - .is_object() + for (path, method) in [ + ("/api/w/{workspace_id}/repositories", "get"), + ("/api/w/{workspace_id}/repositories", "post"), + ("/api/w/{workspace_id}/repositories/{repository_key}", "get"), + ("/api/repositories", "get"), + ("/api/repositories/{repository_key}", "get"), + ] { + let operation = &value["paths"][path][method]; + assert_eq!( + operation["security"][0]["bearerAuth"], + serde_json::json!([]) + ); + assert_eq!( + operation["security"][1]["browserSession"], + serde_json::json!([]) + ); + for status in ["401", "403", "500"] { + assert!( + operation["responses"][status].is_object(), + "missing {method} {path} response {status}" + ); + } + } + let repository_collection = &value["paths"]["/api/w/{workspace_id}/repositories"]; + let repository_create = &repository_collection["post"]; + assert_eq!( + repository_create["security"][0]["bearerAuth"], + serde_json::json!([]) ); - assert!( - value["paths"]["/api/w/{workspace_id}/repositories"]["post"]["responses"]["201"] - .is_object() + assert_eq!( + repository_create["security"][1]["browserSession"], + serde_json::json!([]) + ); + for status in [ + "200", "201", "400", "401", "403", "404", "409", "413", "422", "500", + ] { + assert!( + repository_create["responses"][status].is_object(), + "missing repository-create response {status}" + ); + } + assert_eq!( + repository_create["responses"]["422"]["content"]["application/json"]["schema"]["$ref"], + "#/components/schemas/RepositoryApiError" + ); + assert_eq!( + value["components"]["securitySchemes"]["bearerAuth"]["scheme"], + "bearer" + ); + assert_eq!( + value["components"]["securitySchemes"]["browserSession"]["in"], + "cookie" ); assert!(value["paths"]["/api/w/{workspace_id}/repositories/{repository_key}"] ["get"]["responses"]["404"] diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 5fe926370..74fb99708 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -1604,11 +1604,31 @@ fn signed_request_target(uri: &Uri) -> &str { .unwrap_or_else(|| uri.path()) } +fn repository_api_rejection(path: &str, status: StatusCode, message: &str) -> Response { + if path == "/api/repositories" + || path.starts_with("/api/repositories/") + || (path.starts_with("/api/w/") && path.contains("/repositories")) + { + return ( + status, + Json(server_api::RepositoryApiError::new( + status.as_u16(), + status.canonical_reason().unwrap_or("error"), + message, + Vec::new(), + )), + ) + .into_response(); + } + status.into_response() +} + async fn authorize_scoped_workspace_request( api: &WorkspaceServerApi, workspace_id: &str, request: &mut Request, ) -> std::result::Result<(), Response> { + let request_path = request.uri().path().to_owned(); let proof = request .headers() .get(worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER) @@ -1650,7 +1670,13 @@ async fn authorize_scoped_workspace_request( &digest, ) .await - .map_err(|_| StatusCode::UNAUTHORIZED.into_response())?; + .map_err(|_| { + repository_api_rejection( + &request_path, + StatusCode::UNAUTHORIZED, + "invalid runtime request proof", + ) + })?; request.extensions_mut().insert(source); if !matches!( *request.method(), @@ -1674,7 +1700,13 @@ async fn authorize_scoped_workspace_request( let actor = resolve_server_actor(api, request.headers()) .await .map_err(server_error_response)? - .ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?; + .ok_or_else(|| { + repository_api_rejection( + &request_path, + StatusCode::UNAUTHORIZED, + "authentication required", + ) + })?; let cookie_authenticated = matches!(actor.auth_method, ActorAuthMethod::BrowserSession); let mutating = !matches!( @@ -1690,7 +1722,11 @@ async fn authorize_scoped_workspace_request( .and_then(|value| value.to_str().ok()) .unwrap_or_default() { - return Err(StatusCode::FORBIDDEN.into_response()); + return Err(repository_api_rejection( + &request_path, + StatusCode::FORBIDDEN, + "cross-origin browser mutation denied", + )); } } request.extensions_mut().insert(actor); @@ -1719,6 +1755,7 @@ async fn authorize_workspace_api_request( if !request.uri().path().starts_with("/api/") { return next.run(request).await; } + let request_path = request.uri().path().to_owned(); let public_server_api = is_server_global_forward(request.uri().path()); let workspace_id = api.workspace_id().to_owned(); let proof = request @@ -1762,7 +1799,11 @@ async fn authorize_workspace_api_request( ) .await else { - return StatusCode::UNAUTHORIZED.into_response(); + return repository_api_rejection( + &request_path, + StatusCode::UNAUTHORIZED, + "invalid runtime request proof", + ); }; request.extensions_mut().insert(source); if !matches!( @@ -1795,7 +1836,13 @@ async fn authorize_workspace_api_request( { Ok(Some(actor)) => actor, Ok(None) if public_server_api => return next.run(request).await, - Ok(None) | Err(_) => return StatusCode::UNAUTHORIZED.into_response(), + Ok(None) | Err(_) => { + return repository_api_rejection( + &request_path, + StatusCode::UNAUTHORIZED, + "authentication required", + ); + } }; let cookie_authenticated = matches!(actor.auth_method, ActorAuthMethod::BrowserSession); let mutating = !matches!( @@ -1811,7 +1858,11 @@ async fn authorize_workspace_api_request( .and_then(|value| value.to_str().ok()) .unwrap_or_default() { - return StatusCode::FORBIDDEN.into_response(); + return repository_api_rejection( + &request_path, + StatusCode::FORBIDDEN, + "cross-origin browser mutation denied", + ); } } request.extensions_mut().insert(actor); @@ -1939,7 +1990,7 @@ async fn dispatch_workspace_request( } }; let Some(router) = router else { - return StatusCode::NOT_FOUND.into_response(); + return repository_api_rejection(&path, StatusCode::NOT_FOUND, "workspace was not found"); }; match router.oneshot(request).await { Ok(response) => response, @@ -18844,38 +18895,8 @@ impl ApiError { } fn into_repository_api_error(self) -> server_api::RepositoryApiError { - let status = match &self.error { - Error::WorkspacePermissionDenied(_) | Error::BrowserReopenConfirmationRequired => { - StatusCode::FORBIDDEN - } - Error::RepositoryConflict(_) => StatusCode::CONFLICT, - Error::InvalidInput(_) - | Error::WorkerSourceIdentity(_) - | Error::InvalidRuntimeIdentifier { .. } - | Error::ReservedWorkerName(_) => StatusCode::BAD_REQUEST, - Error::RuntimeOperationFailed { code, .. } if code == "repository_not_configured" => { - StatusCode::NOT_FOUND - } - Error::RuntimeOperationFailed { code, .. } - if code == "repository_provider_unsupported" => - { - StatusCode::BAD_REQUEST - } - Error::InvalidRecordId(_) - | Error::MissingFrontmatter(_) - | Error::UnknownRepository(_) - | Error::UnknownRuntime(_) - | Error::UnknownWorker { .. } - | Error::RuntimeBindingNotFound { .. } - | Error::UnknownHost(_) - | Error::WorkspaceIdMismatch => StatusCode::NOT_FOUND, - _ => StatusCode::INTERNAL_SERVER_ERROR, - }; - let message = if status == StatusCode::INTERNAL_SERVER_ERROR { - "internal server error".to_owned() - } else { - self.error.to_string() - }; + let status = api_error_status(&self.error); + let message = api_error_response_message(&self.error); server_api::RepositoryApiError::new( status.as_u16(), status.canonical_reason().unwrap_or("error"), @@ -18885,125 +18906,129 @@ impl ApiError { } } +fn api_error_status(error: &Error) -> StatusCode { + match error { + Error::BrowserReopenConfirmationRequired | Error::WorkspacePermissionDenied(_) => { + StatusCode::FORBIDDEN + } + Error::TicketAssignmentConflict(_) + | Error::WorkdirAttachmentConflict(_) + | Error::WorkspaceConfigConflict(_) + | Error::RuntimeBindingConflict(_) + | Error::RuntimeBindingRevisionConflict { .. } + | Error::RuntimeBindingFingerprintConflict { .. } + | Error::RepositoryConflict(_) => StatusCode::CONFLICT, + Error::WorkerSourceIdentity(_) | Error::InvalidInput(_) => StatusCode::BAD_REQUEST, + Error::InvalidRuntimeIdentifier { .. } | Error::ReservedWorkerName(_) => { + StatusCode::BAD_REQUEST + } + Error::Ticket(ticket::TicketError::NotFound(_)) + | Error::MergeRequest(merge_request::MergeRequestError::NotFound) => StatusCode::NOT_FOUND, + Error::MergeRequest(merge_request::MergeRequestError::Validation(_)) => { + StatusCode::BAD_REQUEST + } + Error::MergeRequest(_) => StatusCode::CONFLICT, + Error::Ticket( + ticket::TicketError::Ambiguous { .. } + | ticket::TicketError::Locked { .. } + | ticket::TicketError::Conflict(_), + ) => StatusCode::CONFLICT, + Error::Ticket( + ticket::TicketError::InvalidPathComponent(_) + | ticket::TicketError::PathEscapesRoot { .. }, + ) => StatusCode::BAD_REQUEST, + Error::InvalidRecordId(_) + | Error::MissingFrontmatter(_) + | Error::UnknownHost(_) + | Error::UnknownRuntime(_) + | Error::UnknownWorker { .. } + | Error::UnknownRepository(_) + | Error::RuntimeBindingNotFound { .. } + | Error::WorkspaceIdMismatch => StatusCode::NOT_FOUND, + Error::RuntimeOperationFailed { code, .. } if code == "skill_not_found" => { + StatusCode::NOT_FOUND + } + Error::RuntimeCapabilityUnsupported { .. } => StatusCode::NOT_IMPLEMENTED, + Error::RuntimeOperationFailed { code, .. } if code == "repository_not_configured" => { + StatusCode::NOT_FOUND + } + Error::RuntimeOperationFailed { code, .. } if code == "repository_provider_unsupported" => { + StatusCode::BAD_REQUEST + } + Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_auth_failed" => { + StatusCode::UNAUTHORIZED + } + Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_timeout" => { + StatusCode::GATEWAY_TIMEOUT + } + Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_unsupported" => { + StatusCode::NOT_IMPLEMENTED + } + Error::RuntimeOperationFailed { code, .. } + if code == "profile_registry_revision_conflict" + || code == "profile_source_revision_conflict" + || code == "workspace_metadata_revision_conflict" + || code == "workspace_cleanup_plan_stale" + || code == "workspace_cleanup_worker_blocked" + || code == "workspace_cleanup_workdir_blocked" + || code == "workspace_cleanup_worker_pinned" => + { + StatusCode::CONFLICT + } + Error::RuntimeOperationFailed { code, .. } + if code == "unknown_profile_source" + || code == "unknown_profile_selector" + || code == "unknown_objective" => + { + StatusCode::NOT_FOUND + } + Error::RuntimeOperationFailed { code, .. } + if code == "workspace_display_name_invalid" || code.starts_with("profile_") => + { + StatusCode::BAD_REQUEST + } + Error::RuntimeOperationFailed { code, .. } if code.ends_with("_blocked") => { + StatusCode::CONFLICT + } + Error::RuntimeOperationFailed { code, .. } + if code.starts_with("workspace_settings_") + || code.starts_with("invalid_") + || code.starts_with("unsupported_worker_profile") + || code.starts_with("working_directory_") + || code.starts_with("workspace_cleanup_") + || code == "default_runtime_not_configured" + || code == "workspace_worker_workdir_required" + || code.ends_with("_already_exists") + || code.ends_with("_not_config_managed") + || code.ends_with("_unsupported") => + { + StatusCode::BAD_REQUEST + } + Error::RuntimeOperationFailed { code, .. } + if code == "runtime_capacity_unavailable" + || code == "runtime_workdir_capacity_unavailable" => + { + StatusCode::SERVICE_UNAVAILABLE + } + Error::WorkspaceSigningIdentity { .. } => StatusCode::SERVICE_UNAVAILABLE, + Error::RuntimeOperationFailed { .. } => StatusCode::BAD_GATEWAY, + _ => StatusCode::INTERNAL_SERVER_ERROR, + } +} + +fn api_error_response_message(error: &Error) -> String { + match error { + Error::RuntimeOperationFailed { code, message, .. } => { + format!("{code}: {}", sanitize_backend_error(message)) + } + _ => sanitize_backend_error(&error.to_string()), + } +} + impl IntoResponse for ApiError { fn into_response(self) -> Response { - let status = match &self.error { - Error::BrowserReopenConfirmationRequired | Error::WorkspacePermissionDenied(_) => { - StatusCode::FORBIDDEN - } - Error::TicketAssignmentConflict(_) - | Error::WorkdirAttachmentConflict(_) - | Error::WorkspaceConfigConflict(_) - | Error::RuntimeBindingConflict(_) - | Error::RuntimeBindingRevisionConflict { .. } - | Error::RuntimeBindingFingerprintConflict { .. } - | Error::RepositoryConflict(_) => StatusCode::CONFLICT, - Error::WorkerSourceIdentity(_) | Error::InvalidInput(_) => StatusCode::BAD_REQUEST, - Error::InvalidRuntimeIdentifier { .. } | Error::ReservedWorkerName(_) => { - StatusCode::BAD_REQUEST - } - Error::Ticket(ticket::TicketError::NotFound(_)) - | Error::MergeRequest(merge_request::MergeRequestError::NotFound) => { - StatusCode::NOT_FOUND - } - Error::MergeRequest(merge_request::MergeRequestError::Validation(_)) => { - StatusCode::BAD_REQUEST - } - Error::MergeRequest(_) => StatusCode::CONFLICT, - Error::Ticket( - ticket::TicketError::Ambiguous { .. } - | ticket::TicketError::Locked { .. } - | ticket::TicketError::Conflict(_), - ) => StatusCode::CONFLICT, - Error::Ticket( - ticket::TicketError::InvalidPathComponent(_) - | ticket::TicketError::PathEscapesRoot { .. }, - ) => StatusCode::BAD_REQUEST, - Error::InvalidRecordId(_) - | Error::MissingFrontmatter(_) - | Error::UnknownHost(_) - | Error::UnknownRuntime(_) - | Error::UnknownWorker { .. } - | Error::UnknownRepository(_) - | Error::RuntimeBindingNotFound { .. } - | Error::WorkspaceIdMismatch => StatusCode::NOT_FOUND, - Error::RuntimeOperationFailed { code, .. } if code == "skill_not_found" => { - StatusCode::NOT_FOUND - } - Error::RuntimeCapabilityUnsupported { .. } => StatusCode::NOT_IMPLEMENTED, - Error::RuntimeOperationFailed { code, .. } if code == "repository_not_configured" => { - StatusCode::NOT_FOUND - } - Error::RuntimeOperationFailed { code, .. } - if code == "repository_provider_unsupported" => - { - StatusCode::BAD_REQUEST - } - Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_auth_failed" => { - StatusCode::UNAUTHORIZED - } - Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_timeout" => { - StatusCode::GATEWAY_TIMEOUT - } - Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_unsupported" => { - StatusCode::NOT_IMPLEMENTED - } - Error::RuntimeOperationFailed { code, .. } - if code == "profile_registry_revision_conflict" - || code == "profile_source_revision_conflict" - || code == "workspace_metadata_revision_conflict" - || code == "workspace_cleanup_plan_stale" - || code == "workspace_cleanup_worker_blocked" - || code == "workspace_cleanup_workdir_blocked" - || code == "workspace_cleanup_worker_pinned" => - { - StatusCode::CONFLICT - } - Error::RuntimeOperationFailed { code, .. } - if code == "unknown_profile_source" - || code == "unknown_profile_selector" - || code == "unknown_objective" => - { - StatusCode::NOT_FOUND - } - Error::RuntimeOperationFailed { code, .. } - if code == "workspace_display_name_invalid" || code.starts_with("profile_") => - { - StatusCode::BAD_REQUEST - } - Error::RuntimeOperationFailed { code, .. } if code.ends_with("_blocked") => { - StatusCode::CONFLICT - } - Error::RuntimeOperationFailed { code, .. } - if code.starts_with("workspace_settings_") - || code.starts_with("invalid_") - || code.starts_with("unsupported_worker_profile") - || code.starts_with("working_directory_") - || code.starts_with("workspace_cleanup_") - || code == "default_runtime_not_configured" - || code == "workspace_worker_workdir_required" - || code.ends_with("_already_exists") - || code.ends_with("_not_config_managed") - || code.ends_with("_unsupported") => - { - StatusCode::BAD_REQUEST - } - Error::RuntimeOperationFailed { code, .. } - if code == "runtime_capacity_unavailable" - || code == "runtime_workdir_capacity_unavailable" => - { - StatusCode::SERVICE_UNAVAILABLE - } - Error::WorkspaceSigningIdentity { .. } => StatusCode::SERVICE_UNAVAILABLE, - Error::RuntimeOperationFailed { .. } => StatusCode::BAD_GATEWAY, - _ => StatusCode::INTERNAL_SERVER_ERROR, - }; - let response_message = match &self.error { - Error::RuntimeOperationFailed { code, message, .. } => { - format!("{code}: {}", sanitize_backend_error(message)) - } - _ => sanitize_backend_error(&self.error.to_string()), - }; + let status = api_error_status(&self.error); + let response_message = api_error_response_message(&self.error); let log = ApiErrorLog { kind: self .diagnostics @@ -22881,6 +22906,93 @@ mod tests { .body(Body::from(body.to_string())) .unwrap() }; + let anonymous_repository = app + .clone() + .oneshot( + Request::builder() + .uri(&repositories_uri) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(anonymous_repository.status(), StatusCode::UNAUTHORIZED); + let anonymous_body: Value = serde_json::from_slice( + &to_bytes(anonymous_repository.into_body(), usize::MAX) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(anonymous_body["error"], "Unauthorized"); + + let unknown_field = app + .clone() + .oneshot(create_repository(serde_json::json!({ + "repository_key": "unknown-field", + "source": temp.path().join("unknown-field").display().to_string(), + "unexpected": true + }))) + .await + .unwrap(); + assert_eq!(unknown_field.status(), StatusCode::UNPROCESSABLE_ENTITY); + let unknown_field_body: Value = serde_json::from_slice( + &to_bytes(unknown_field.into_body(), usize::MAX) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(unknown_field_body["error"], "Unprocessable Entity"); + + let malformed = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri(&repositories_uri) + .header( + axum::http::header::COOKIE, + "yoi_workspace_session=browser-session-auth", + ) + .header(ORIGIN, &expected_origin) + .header(CONTENT_TYPE, "application/json") + .body(Body::from("{")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(malformed.status(), StatusCode::BAD_REQUEST); + assert!( + serde_json::from_slice::( + &to_bytes(malformed.into_body(), usize::MAX).await.unwrap() + ) + .is_ok() + ); + + let oversized = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri(&repositories_uri) + .header( + axum::http::header::COOKIE, + "yoi_workspace_session=browser-session-auth", + ) + .header(ORIGIN, &expected_origin) + .header(CONTENT_TYPE, "application/json") + .body(Body::from(vec![b' '; 5 * 1024 * 1024 + 1])) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(oversized.status(), StatusCode::PAYLOAD_TOO_LARGE); + assert!( + serde_json::from_slice::( + &to_bytes(oversized.into_body(), usize::MAX).await.unwrap() + ) + .is_ok() + ); + let created = app .clone() .oneshot(create_repository(repository_request.clone())) diff --git a/openapi/server-api.json b/openapi/server-api.json index c970e99e0..3d0ea2456 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -337,12 +337,23 @@ "string": { "type": "string" } + }, + "securitySchemes": { + "bearerAuth": { + "scheme": "bearer", + "type": "http" + }, + "browserSession": { + "in": "cookie", + "name": "yoi_workspace_session", + "type": "apiKey" + } } }, "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:317cc65b39674bd229abc364d2daea3e909215471da5828313b3cf3ee2142600" + "x-yoi-source-digest": "sha256:9ba281f960b7a6bf8f1e8e29c1a5821ab5d8808f53a7b0f98abe76832e658189" }, "openapi": "3.1.0", "paths": { @@ -369,8 +380,56 @@ } }, "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] } }, "/api/repositories/{repository_key}": { @@ -399,6 +458,36 @@ }, "description": "Successful response" }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, "404": { "content": { "application/json": { @@ -408,8 +497,26 @@ } }, "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] } }, "/api/w/{workspace_id}/repositories": { @@ -447,8 +554,56 @@ } }, "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] }, "post": { "operationId": "repository_create", @@ -504,8 +659,86 @@ } }, "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] } }, "/api/w/{workspace_id}/repositories/{repository_key}": { @@ -544,6 +777,36 @@ }, "description": "Successful response" }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, "404": { "content": { "application/json": { @@ -553,8 +816,26 @@ } }, "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] } } } From bbd949e57f8bb7612ea3a3d12d4b30f7fa04ddf3 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 18 Sep 2026 18:37:55 +0900 Subject: [PATCH 6/8] Digest rendered OpenAPI contract --- crates/server-api/src/lib.rs | 28 +++++++++++++--------------- openapi/server-api.json | 2 +- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 8b241e663..cb4bc6515 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -172,32 +172,30 @@ pub trait ServerApi { ) -> Result; } -/// Digest of every authoritative input used to derive the canonical OpenAPI artifact. -pub fn canonical_openapi_source_digest() -> String { - let mut hasher = Sha256::new(); - for source in [ - include_bytes!("lib.rs").as_slice(), - include_bytes!("../../api-macros-impl/src/lib.rs").as_slice(), - include_bytes!("../../api-macros/src/lib.rs").as_slice(), - include_bytes!("../../api-macros/src/openapi.rs").as_slice(), - ] { - hasher.update((source.len() as u64).to_le_bytes()); - hasher.update(source); - } - let digest = hasher.finalize(); +/// Digest of the fully rendered canonical contract with its digest slot normalized. +pub fn canonical_openapi_source_digest() -> Result { + const NORMALIZED_DIGEST: &str = + "sha256:0000000000000000000000000000000000000000000000000000000000000000"; + let normalized = server_api_openapi(api_macros::openapi::OpenApiInfo { + title: "Yoi Server API", + version: env!("CARGO_PKG_VERSION"), + source_digest: NORMALIZED_DIGEST, + })? + .to_json()?; + let digest = Sha256::digest(normalized.as_bytes()); let mut encoded = String::with_capacity("sha256:".len() + digest.len() * 2); encoded.push_str("sha256:"); for byte in digest { use std::fmt::Write as _; write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail"); } - encoded + Ok(encoded) } /// Build the deployment-independent canonical ServerApi OpenAPI document. pub fn canonical_openapi_document() -> Result { - let source_digest = canonical_openapi_source_digest(); + let source_digest = canonical_openapi_source_digest()?; server_api_openapi(api_macros::openapi::OpenApiInfo { title: "Yoi Server API", version: env!("CARGO_PKG_VERSION"), diff --git a/openapi/server-api.json b/openapi/server-api.json index 3d0ea2456..3d7ee96dc 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -353,7 +353,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:9ba281f960b7a6bf8f1e8e29c1a5821ab5d8808f53a7b0f98abe76832e658189" + "x-yoi-source-digest": "sha256:37e4ff70a1e2a6fb75a514b03a6d14306ca7fc96ef53a3271186f29acc558e00" }, "openapi": "3.1.0", "paths": { From 7a4a98820868288bd02e5255756da9529ff65938 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 18 Sep 2026 18:56:06 +0900 Subject: [PATCH 7/8] Document media type and deletion conflicts --- crates/server-api/src/lib.rs | 4 +- crates/workspace-server/src/server.rs | 53 +++++++++++++++++++++++++-- openapi/server-api.json | 12 +++++- 3 files changed, 63 insertions(+), 6 deletions(-) diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index cb4bc6515..bc1e943a6 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -159,7 +159,7 @@ pub trait ServerApi { status = 201, alternate_status = 200, error_status = 400, - additional_error_statuses = [401, 403, 404, 409, 413, 422, 500], + additional_error_statuses = [401, 403, 404, 409, 413, 415, 422, 500], bearer_auth = true, browser_auth = true, normalize_body_errors = true @@ -5236,7 +5236,7 @@ mod openapi_artifact_tests { serde_json::json!([]) ); for status in [ - "200", "201", "400", "401", "403", "404", "409", "413", "422", "500", + "200", "201", "400", "401", "403", "404", "409", "413", "415", "422", "500", ] { assert!( repository_create["responses"][status].is_object(), diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 74fb99708..9ca63cad4 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -1688,11 +1688,11 @@ async fn authorize_scoped_workspace_request( .map_err(server_error_response)? .is_some_and(|workspace| workspace.state == "active") { - return Err(( + return Err(repository_api_rejection( + &request_path, StatusCode::CONFLICT, "Workspace is deleting and no longer accepts mutations", - ) - .into_response()); + )); } return Ok(()); } @@ -19113,6 +19113,24 @@ mod tests { } } + #[tokio::test] + async fn repository_conflict_rejections_use_the_typed_error_contract() { + let response = repository_api_rejection( + "/api/w/workspace-test/repositories", + StatusCode::CONFLICT, + "Workspace is deleting and no longer accepts mutations", + ); + assert_eq!(response.status(), StatusCode::CONFLICT); + let body: server_api::RepositoryApiError = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert_eq!(body.error, "Conflict"); + assert_eq!( + body.message, + "Workspace is deleting and no longer accepts mutations" + ); + } + #[test] fn backend_resource_timeout_maps_to_gateway_timeout() { assert_eq!( @@ -22968,6 +22986,35 @@ mod tests { .is_ok() ); + let unsupported_media_type = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri(&repositories_uri) + .header( + axum::http::header::COOKIE, + "yoi_workspace_session=browser-session-auth", + ) + .header(ORIGIN, &expected_origin) + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + unsupported_media_type.status(), + StatusCode::UNSUPPORTED_MEDIA_TYPE + ); + assert!( + serde_json::from_slice::( + &to_bytes(unsupported_media_type.into_body(), usize::MAX) + .await + .unwrap() + ) + .is_ok() + ); + let oversized = app .clone() .oneshot( diff --git a/openapi/server-api.json b/openapi/server-api.json index 3d7ee96dc..4e45d76b0 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -353,7 +353,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:37e4ff70a1e2a6fb75a514b03a6d14306ca7fc96ef53a3271186f29acc558e00" + "x-yoi-source-digest": "sha256:bac8ba9340cf541183e96715dd4ccd8367f965cdcfc398e6d5091f26a903e1ef" }, "openapi": "3.1.0", "paths": { @@ -710,6 +710,16 @@ }, "description": "Error response" }, + "415": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, "422": { "content": { "application/json": { From 852a4051820e21fcb1ac31b8518fd8057631ea66 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 18 Sep 2026 19:12:42 +0900 Subject: [PATCH 8/8] Normalize deleting-workspace repository conflicts --- crates/workspace-server/src/server.rs | 75 +++++++++++++++++++++++---- 1 file changed, 64 insertions(+), 11 deletions(-) diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 9ca63cad4..b30e1077a 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -1640,7 +1640,13 @@ async fn authorize_scoped_workspace_request( let body = std::mem::take(request.body_mut()); let body = axum::body::to_bytes(body, 16 * 1024 * 1024) .await - .map_err(|_| StatusCode::BAD_REQUEST.into_response())?; + .map_err(|_| { + repository_api_rejection( + &request_path, + StatusCode::BAD_REQUEST, + "invalid request body", + ) + })?; let digest = worker_runtime::auth::request_body_digest(&body); *request.body_mut() = axum::body::Body::from(body); let permission = if path @@ -1738,11 +1744,11 @@ async fn authorize_scoped_workspace_request( .map_err(server_error_response)? .is_some_and(|workspace| workspace.state == "active") { - return Err(( + return Err(repository_api_rejection( + &request_path, StatusCode::CONFLICT, "Workspace is deleting and no longer accepts mutations", - ) - .into_response()); + )); } Ok(()) } @@ -1768,7 +1774,11 @@ async fn authorize_workspace_api_request( let path = signed_request_target(request.uri()).to_owned(); let body = std::mem::take(request.body_mut()); let Ok(body) = axum::body::to_bytes(body, 16 * 1024 * 1024).await else { - return StatusCode::BAD_REQUEST.into_response(); + return repository_api_rejection( + &request_path, + StatusCode::BAD_REQUEST, + "invalid request body", + ); }; let digest = worker_runtime::auth::request_body_digest(&body); *request.body_mut() = axum::body::Body::from(body); @@ -1817,11 +1827,11 @@ async fn authorize_workspace_api_request( .flatten() .is_some_and(|workspace| workspace.state == "active") { - return ( + return repository_api_rejection( + &request_path, StatusCode::CONFLICT, "Workspace is deleting and no longer accepts mutations", - ) - .into_response(); + ); } return next.run(request).await; } @@ -1875,11 +1885,11 @@ async fn authorize_workspace_api_request( .flatten() .is_some_and(|workspace| workspace.state == "active") { - return ( + return repository_api_rejection( + &request_path, StatusCode::CONFLICT, "Workspace is deleting and no longer accepts mutations", - ) - .into_response(); + ); } next.run(request).await } @@ -23384,6 +23394,7 @@ mod tests { .unwrap(); assert_eq!(mixed_auth_csrf_rejected.status(), StatusCode::FORBIDDEN); + let deleting_app = app.clone(); let csrf_accepted = app .oneshot( Request::builder() @@ -23401,6 +23412,48 @@ mod tests { .await .unwrap(); assert_ne!(csrf_accepted.status(), StatusCode::FORBIDDEN); + + let preflight = store + .workspace_deletion_preflight("account-auth", &workspace.workspace.workspace_id) + .unwrap(); + store + .reserve_workspace_deletion( + "account-auth", + &workspace.workspace.workspace_id, + &server_api::WorkspaceDeletionRequest { + operation_id: "repository-conflict-test".to_owned(), + expected_revision: preflight.expected_revision, + confirmation: preflight.display_name, + }, + ) + .unwrap(); + let deleting_conflict = deleting_app + .oneshot( + Request::builder() + .method(Method::POST) + .uri(&repositories_uri) + .header(axum::http::header::AUTHORIZATION, "Bearer api-token-auth") + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::json!({ + "repository_key": "blocked-by-deletion", + "source": temp.path().join("blocked-by-deletion").display().to_string() + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(deleting_conflict.status(), StatusCode::CONFLICT); + let deleting_body: server_api::RepositoryApiError = serde_json::from_slice( + &to_bytes(deleting_conflict.into_body(), usize::MAX) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(deleting_body.error, "Conflict"); + assert!(deleting_body.message.contains("deleting")); } #[tokio::test]