From e12b555f43a67cc38ec34b69e0775415d25efe1b Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 18 Sep 2026 17:17:02 +0900 Subject: [PATCH] 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" + } + } + } + } + } +}