Migrate repository routes to ServerApi

This commit is contained in:
2026-09-18 17:17:02 +09:00
parent bd07f3a66f
commit e12b555f43
11 changed files with 1020 additions and 49 deletions
Generated
+2
View File
@@ -4157,8 +4157,10 @@ dependencies = [
"api-macros", "api-macros",
"protocol", "protocol",
"runtime-api", "runtime-api",
"schemars",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.11.0",
"tokio", "tokio",
"ts-rs", "ts-rs",
"webauthn-rs-proto", "webauthn-rs-proto",
+110 -12
View File
@@ -104,7 +104,9 @@ struct RouteArgs {
path: LitStr, path: LitStr,
operation_id: Option<LitStr>, operation_id: Option<LitStr>,
status: Option<LitInt>, status: Option<LitInt>,
alternate_status: Option<LitInt>,
error_status: Option<LitInt>, error_status: Option<LitInt>,
openapi: Option<syn::LitBool>,
} }
impl Parse for RouteArgs { impl Parse for RouteArgs {
@@ -114,7 +116,9 @@ impl Parse for RouteArgs {
path, path,
operation_id: None, operation_id: None,
status: None, status: None,
alternate_status: None,
error_status: None, error_status: None,
openapi: None,
}; };
while !input.is_empty() { while !input.is_empty() {
@@ -132,12 +136,24 @@ impl Parse for RouteArgs {
"operation_id", "operation_id",
)?, )?,
"status" => set_once(&mut result.status, input.parse::<LitInt>()?, &key, "status")?, "status" => set_once(&mut result.status, input.parse::<LitInt>()?, &key, "status")?,
"alternate_status" => set_once(
&mut result.alternate_status,
input.parse::<LitInt>()?,
&key,
"alternate_status",
)?,
"error_status" => set_once( "error_status" => set_once(
&mut result.error_status, &mut result.error_status,
input.parse::<LitInt>()?, input.parse::<LitInt>()?,
&key, &key,
"error_status", "error_status",
)?, )?,
"openapi" => set_once(
&mut result.openapi,
input.parse::<syn::LitBool>()?,
&key,
"openapi",
)?,
_ => { _ => {
return Err(syn::Error::new( return Err(syn::Error::new(
key.span(), key.span(),
@@ -167,6 +183,9 @@ enum Location {
Query, Query,
Header, Header,
Body, 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 { impl Location {
@@ -176,6 +195,7 @@ impl Location {
Self::Query => quote!(Query), Self::Query => quote!(Query),
Self::Header => quote!(Header), Self::Header => quote!(Header),
Self::Body => quote!(Body), Self::Body => quote!(Body),
Self::Extension => unreachable!("extensions are not wire parameters"),
}; };
quote!(#api_crate::ParameterLocation::#variant) quote!(#api_crate::ParameterLocation::#variant)
} }
@@ -201,7 +221,9 @@ struct Operation {
error_body: Option<Type>, error_body: Option<Type>,
fallible: bool, fallible: bool,
response_status: u16, response_status: u16,
alternate_status: Option<u16>,
error_status: Option<u16>, error_status: Option<u16>,
openapi_skip: bool,
} }
struct ApiDefinition { struct ApiDefinition {
@@ -392,6 +414,21 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result<Operation> {
"status must be a success status between 200 and 299", "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")?; let error_status = parse_status(route.error_status.as_ref(), "error_status")?;
if error_status.is_some() && error_body.is_none() { if error_status.is_some() && error_body.is_none() {
return Err(syn::Error::new_spanned( return Err(syn::Error::new_spanned(
@@ -464,11 +501,20 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result<Operation> {
} }
(Location::Body, rust_name.clone()) (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 if placeholders.contains(&rust_name) => (Location::Path, rust_name.clone()),
None => { None => {
return Err(syn::Error::new( return Err(syn::Error::new(
ident.span(), 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<Operation> {
error_body, error_body,
fallible, fallible,
response_status, response_status,
alternate_status,
error_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<Attribute>) -> syn::Result<Option<(Location, Op
Some("query") => Some(Location::Query), Some("query") => Some(Location::Query),
Some("header") => Some(Location::Header), Some("header") => Some(Location::Header),
Some("body") => Some(Location::Body), Some("body") => Some(Location::Body),
Some("extension") => Some(Location::Extension),
Some("stream") | Some("multipart") | Some("binary") => { Some("stream") | Some("multipart") | Some("binary") => {
return Err(syn::Error::new_spanned( return Err(syn::Error::new_spanned(
attr, attr,
@@ -881,10 +930,13 @@ fn reqwest_adapter_tokens(
let builder_ident = format_ident!("{}ClientBuilder", ident_text(trait_ident)); let builder_ident = format_ident!("{}ClientBuilder", ident_text(trait_ident));
let methods = api.operations.iter().map(|operation| { let methods = api.operations.iter().map(|operation| {
let method_ident = &operation.method_ident; 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 = &parameter.rust_ident; let ident = &parameter.rust_ident;
let ty = &parameter.ty; let ty = &parameter.ty;
quote!(#ident: #ty) Some(quote!(#ident: #ty))
}); });
let path_segments = operation let path_segments = operation
.path .path
@@ -928,6 +980,10 @@ fn reqwest_adapter_tokens(
.unwrap_or_else(|| quote!(::core::option::Option::None)); .unwrap_or_else(|| quote!(::core::option::Option::None));
let method = reqwest_method_tokens(operation.method, api_crate); let method = reqwest_method_tokens(operation.method, api_crate);
let response_status = operation.response_status; 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 let response_type = operation
.response_body .response_body
.as_ref() .as_ref()
@@ -985,7 +1041,7 @@ fn reqwest_adapter_tokens(
.await .await
.map_err(#api_crate::reqwest::ClientError::from)?; .map_err(#api_crate::reqwest::ClientError::from)?;
let __actual = __response.status().as_u16(); let __actual = __response.status().as_u16();
if __actual == #response_status { if #accepted_status {
#success #success
} }
#public_error #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 = &parameter.rust_ident;
let ty = &parameter.ty;
Some(quote!(#api_crate::axum::framework::Extension(#ident): #api_crate::axum::framework::Extension<#ty>,))
});
let body_extractor = operation.parameters.iter().find_map(|parameter| { let body_extractor = operation.parameters.iter().find_map(|parameter| {
if !matches!(parameter.location, Location::Body) { if !matches!(parameter.location, Location::Body) {
return None; return None;
@@ -1191,7 +1255,16 @@ fn axum_adapter_tokens(
}); });
let call_arguments = operation.parameters.iter().map(|parameter| &parameter.rust_ident); let call_arguments = operation.parameters.iter().map(|parameter| &parameter.rust_ident);
let response_status = operation.response_status; 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)) quote!(#api_crate::axum::json_response(#api_crate::axum::status(#response_status), value))
} else { } else {
quote!({ let _ = value; #api_crate::axum::empty_response(#api_crate::axum::status(#response_status)) }) quote!({ let _ = value; #api_crate::axum::empty_response(#api_crate::axum::status(#response_status)) })
@@ -1232,6 +1305,7 @@ fn axum_adapter_tokens(
#path_extractor #path_extractor
#(#query_extractors)* #(#query_extractors)*
#header_extractor #header_extractor
#(#extension_extractors)*
#body_extractor #body_extractor
) -> #api_crate::axum::framework::Response ) -> #api_crate::axum::framework::Response
where where
@@ -1285,12 +1359,16 @@ fn openapi_adapter_tokens(
to_snake_case(&api.item.ident), to_snake_case(&api.item.ident),
span = api.item.ident.span() 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 method = method_name(operation.method);
let path = &operation.path; let path = &operation.path;
let operation_id = &operation.operation_id; let operation_id = &operation.operation_id;
let parameters = operation.parameters.iter().filter_map(|parameter| { let parameters = operation.parameters.iter().filter_map(|parameter| {
if matches!(parameter.location, Location::Body) { if matches!(parameter.location, Location::Body | Location::Extension) {
return None; return None;
} }
let ty = &parameter.ty; let ty = &parameter.ty;
@@ -1299,7 +1377,7 @@ fn openapi_adapter_tokens(
Location::Path => "path", Location::Path => "path",
Location::Query => "query", Location::Query => "query",
Location::Header => "header", Location::Header => "header",
Location::Body => unreachable!(), Location::Body | Location::Extension => unreachable!(),
}; };
Some(quote! { Some(quote! {
operation.parameter::<#ty>(#name, #location)?; operation.parameter::<#ty>(#name, #location)?;
@@ -1319,6 +1397,16 @@ fn openapi_adapter_tokens(
operation.empty_response(#status, "Successful response")?; 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 let error = operation
.error_status .error_status
.zip(operation.error_body.as_ref()) .zip(operation.error_body.as_ref())
@@ -1334,6 +1422,7 @@ fn openapi_adapter_tokens(
#(#parameters)* #(#parameters)*
#request #request
#response #response
#alternate_response
#error #error
operation.finish()?; operation.finish()?;
} }
@@ -1383,7 +1472,13 @@ fn expand_api(api: ApiDefinition) -> syn::Result<proc_macro2::TokenStream> {
.map(|operation| { .map(|operation| {
let marker_ident = &operation.marker_ident; let marker_ident = &operation.marker_ident;
let metadata = metadata_tokens(operation, &api_crate); let metadata = metadata_tokens(operation, &api_crate);
let parameter_types = operation.parameters.iter().map(|parameter| &parameter.ty); let parameter_types = operation.parameters.iter().filter_map(|parameter| {
if matches!(parameter.location, Location::Extension) {
None
} else {
Some(&parameter.ty)
}
});
let request_type = operation let request_type = operation
.request_body .request_body
.as_ref() .as_ref()
@@ -1472,17 +1567,20 @@ fn metadata_tokens(
}, },
None => quote!(::core::option::Option::None), 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 = &parameter.rust_name; let rust_name = &parameter.rust_name;
let wire_name = &parameter.wire_name; let wire_name = &parameter.wire_name;
let location = parameter.location.tokens(api_crate); let location = parameter.location.tokens(api_crate);
quote! { Some(quote! {
#api_crate::ParameterMetadata { #api_crate::ParameterMetadata {
rust_name: #rust_name, rust_name: #rust_name,
wire_name: #wire_name, wire_name: #wire_name,
location: #location, location: #location,
} }
} })
}); });
quote! { quote! {
+1 -1
View File
@@ -52,7 +52,7 @@ pub fn status(code: u16) -> StatusCode {
pub mod framework { pub mod framework {
pub use axum::{ pub use axum::{
Json, Router, Json, Router,
extract::{Path, Query, State}, extract::{Extension, Path, Query, State},
http::{HeaderMap, StatusCode}, http::{HeaderMap, StatusCode},
response::Response, response::Response,
routing::{delete, get, head, options, patch, post, put}, routing::{delete, get, head, options, patch, post, put},
+14 -3
View File
@@ -38,13 +38,17 @@
//! `options`. The first argument is a path template. `operation_id` defaults to the Rust //! `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 //! 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 //! names evolve. Success status defaults to `200`, except an empty (`()`) response defaults to
//! `204`. A public error inferred from `Result<T, E>` 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<T, E>` defaults to status `400`.
//! //!
//! Arguments are classified with `#[body]`, `#[query]`, `#[header]`, or `#[path]`. An //! Arguments are classified with `#[body]`, `#[query]`, `#[header]`, `#[path]`, or `#[extension]`.
//! unannotated argument whose Rust name occurs in the route template is inferred as a path //! 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")]`. //! 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 //! 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. //! 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 //! # Generated names
//! //!
@@ -200,6 +204,13 @@ pub trait HttpError {
fn status_code(&self) -> u16; 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. /// Marker used when an operation has no request, response, or public error body.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum NoBody {} pub enum NoBody {}
+4
View File
@@ -192,6 +192,10 @@ impl BackendApiClient {
format!("Bearer {}", self.access_token.0) format!("Bearer {}", self.access_token.0)
} }
pub(crate) fn asynchronous_client(&self) -> reqwest::Client {
self.asynchronous.clone()
}
pub async fn require_success( pub async fn require_success(
&self, &self,
response: reqwest::Response, response: reqwest::Response,
+62 -9
View File
@@ -1,9 +1,8 @@
use crate::{BackendApiClient, BackendApiClientError}; use crate::{BackendApiClient, BackendApiClientError};
use reqwest::Method; use reqwest::Method;
use server_api::{ use server_api::{
InitialRepositoryIntent, RepositoryListResponse, RepositorySummary, InitialRepositoryIntent, RepositorySummary, WorkspaceCatalogListResponse,
WorkspaceCatalogListResponse, WorkspaceCreateRequest, WorkspaceCreateResponse, WorkspaceCreateRequest, WorkspaceCreateResponse, WorkspaceSummary,
WorkspaceSummary,
}; };
use std::fmt; use std::fmt;
@@ -32,6 +31,7 @@ pub enum BackendWorkspaceClientError {
InvalidTarget(String), InvalidTarget(String),
Api(BackendApiClientError), Api(BackendApiClientError),
Http(reqwest::Error), Http(reqwest::Error),
RepositoryApi(server_api::client_support::ClientError<server_api::RepositoryApiError>),
} }
impl fmt::Display for BackendWorkspaceClientError { impl fmt::Display for BackendWorkspaceClientError {
@@ -40,6 +40,7 @@ impl fmt::Display for BackendWorkspaceClientError {
Self::InvalidTarget(message) => f.write_str(message), Self::InvalidTarget(message) => f.write_str(message),
Self::Api(error) => write!(f, "{error}"), Self::Api(error) => write!(f, "{error}"),
Self::Http(error) => write!(f, "{error}"), Self::Http(error) => write!(f, "{error}"),
Self::RepositoryApi(error) => write!(f, "{error}"),
} }
} }
} }
@@ -58,6 +59,39 @@ impl From<reqwest::Error> for BackendWorkspaceClientError {
} }
} }
impl From<server_api::client_support::ClientError<server_api::RepositoryApiError>>
for BackendWorkspaceClientError
{
fn from(
error: server_api::client_support::ClientError<server_api::RepositoryApiError>,
) -> 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<reqwest::header::HeaderMap, server_api::client_support::AuthorizationError> {
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( pub fn list_backend_workspaces_blocking(
target: &BackendWorkspaceCatalogTarget, target: &BackendWorkspaceCatalogTarget,
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> { ) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
@@ -75,6 +109,21 @@ pub fn list_backend_workspaces_blocking(
pub fn list_backend_workspace_repositories_blocking( pub fn list_backend_workspace_repositories_blocking(
target: &BackendWorkspaceCatalogTarget, target: &BackendWorkspaceCatalogTarget,
workspace_id: &str, workspace_id: &str,
) -> Result<Vec<RepositorySummary>, 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<Vec<RepositorySummary>, BackendWorkspaceClientError> { ) -> Result<Vec<RepositorySummary>, BackendWorkspaceClientError> {
if workspace_id.is_empty() if workspace_id.is_empty()
|| workspace_id.len() > 200 || workspace_id.len() > 200
@@ -86,12 +135,16 @@ pub fn list_backend_workspace_repositories_blocking(
"Workspace id returned by Backend is invalid".to_string(), "Workspace id returned by Backend is invalid".to_string(),
)); ));
} }
let client = BackendApiClient::from_stored_token(&target.base_url)?; let client = server_api::ServerApiClient::builder(backend.origin().as_str())
let response = client .map_err(|error| BackendWorkspaceClientError::InvalidTarget(error.to_string()))?
.blocking_request(Method::GET, &format!("/api/w/{workspace_id}/repositories"))? .client(backend.asynchronous_client())
.send()?; .authorizer(RepositoryBearerAuthorizer {
client.check_status(response.status())?; authorization: backend.authorization_header_value(),
let response = response.json::<RepositoryListResponse>()?; })
.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 { if response.workspace_id != workspace_id {
return Err(BackendWorkspaceClientError::InvalidTarget( return Err(BackendWorkspaceClientError::InvalidTarget(
"Repository catalog response does not match the requested Workspace".to_string(), "Repository catalog response does not match the requested Workspace".to_string(),
+3 -1
View File
@@ -10,10 +10,12 @@ default = []
typescript = ["dep:ts-rs", "protocol/typescript"] typescript = ["dep:ts-rs", "protocol/typescript"]
[dependencies] [dependencies]
api-macros = { workspace = true, features = ["reqwest", "axum"] } api-macros = { workspace = true, features = ["reqwest", "axum", "openapi"] }
protocol.workspace = true protocol.workspace = true
runtime-api.workspace = true runtime-api.workspace = true
schemars.workspace = true
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
sha2.workspace = true
ts-rs = { version = "12.0.1", optional = true } ts-rs = { version = "12.0.1", optional = true }
webauthn-rs-proto = "0.5.5" webauthn-rs-proto = "0.5.5"
workdir.workspace = true workdir.workspace = true
@@ -0,0 +1,16 @@
use std::{env, fs, path::PathBuf};
fn main() -> Result<(), Box<dyn std::error::Error>> {
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(())
}
+183 -15
View File
@@ -8,7 +8,9 @@ use api_macros::api;
pub use api_macros::axum as server_support; pub use api_macros::axum as server_support;
pub use api_macros::reqwest as client_support; pub use api_macros::reqwest as client_support;
pub use api_macros::{ApiContract, HttpMethod}; pub use api_macros::{ApiContract, HttpMethod};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use webauthn_rs_proto::{ use webauthn_rs_proto::{
CreationChallengeResponse, PublicKeyCredential, RegisterPublicKeyCredential, CreationChallengeResponse, PublicKeyCredential, RegisterPublicKeyCredential,
RequestChallengeResponse, RequestChallengeResponse,
@@ -17,6 +19,57 @@ use webauthn_rs_proto::{
pub type ServerApiError = runtime_api::RuntimeApiError; pub type ServerApiError = runtime_api::RuntimeApiError;
pub type ServerApiClientError = client_support::ClientError<ServerApiError>; pub type ServerApiClientError = client_support::ClientError<ServerApiError>;
/// 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<Diagnostic>,
#[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<String>,
message: impl Into<String>,
diagnostics: Vec<Diagnostic>,
) -> 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)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct WorkspaceWorkerSessionResponse { pub struct WorkspaceWorkerSessionResponse {
pub subject: WorkspaceWorkerSubject, pub subject: WorkspaceWorkerSubject,
@@ -24,12 +77,13 @@ pub struct WorkspaceWorkerSessionResponse {
pub observation: runtime_api::WorkerSessionAvailability, pub observation: runtime_api::WorkerSessionAvailability,
} }
#[api(reqwest, axum)] #[api(reqwest, axum, openapi)]
pub trait ServerApi { pub trait ServerApi {
#[get( #[get(
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/session", "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/session",
status = 200, status = 200,
error_status = 400 error_status = 400,
openapi = false
)] )]
async fn worker_session( async fn worker_session(
&self, &self,
@@ -37,6 +91,59 @@ pub trait ServerApi {
#[path] runtime_id: String, #[path] runtime_id: String,
#[path] worker_id: String, #[path] worker_id: String,
) -> Result<WorkspaceWorkerSessionResponse, ServerApiError>; ) -> Result<WorkspaceWorkerSessionResponse, ServerApiError>;
#[get("/api/w/{workspace_id}/repositories", status = 200, error_status = 400)]
async fn repository_list(
&self,
#[path] workspace_id: String,
) -> Result<RepositoryListResponse, RepositoryApiError>;
#[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<RepositoryDetailResponse, RepositoryApiError>;
#[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<CreateWorkspaceRepositoryResponse, RepositoryApiError>;
}
/// 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<api_macros::openapi::OpenApiDocument, api_macros::openapi::OpenApiError> {
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. /// 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 /// Local paths remain distinct from network Git transports so callers cannot
/// accidentally treat an unmaterialized remote as a server-local filesystem path. /// 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))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum RepositorySourceKind { pub enum RepositorySourceKind {
@@ -451,7 +558,7 @@ impl<'de> Deserialize<'de> for RepositorySourceKind {
} }
/// Stable Repository source identity stored by Workspace authority. /// 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))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct RepositorySource { pub struct RepositorySource {
pub kind: RepositorySourceKind, pub kind: RepositorySourceKind,
@@ -464,7 +571,7 @@ pub struct RepositorySource {
/// ///
/// The Server parses and canonicalizes `source`; callers cannot assert a /// The Server parses and canonicalizes `source`; callers cannot assert a
/// transport classification or supply credential material through this DTO. /// 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)] #[serde(deny_unknown_fields)]
pub struct CreateWorkspaceRepositoryRequest { pub struct CreateWorkspaceRepositoryRequest {
pub repository_key: String, pub repository_key: String,
@@ -473,14 +580,20 @@ pub struct CreateWorkspaceRepositoryRequest {
pub default_ref: Option<String>, pub default_ref: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
pub struct CreateWorkspaceRepositoryResponse { pub struct CreateWorkspaceRepositoryResponse {
pub workspace_id: String, pub workspace_id: String,
pub repository_key: String, pub repository_key: String,
pub replayed: bool, 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))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum RepositoryObservedStatus { pub enum RepositoryObservedStatus {
@@ -1125,7 +1238,7 @@ pub enum WorkspaceProfileSourceProvenance {
ProjectProfileSourceTree, ProjectProfileSourceTree,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct RepositoryDiagnostic { pub struct RepositoryDiagnostic {
@@ -1134,7 +1247,7 @@ pub struct RepositoryDiagnostic {
pub message: String, 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))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct GitRemoteSummary { pub struct GitRemoteSummary {
@@ -1142,7 +1255,7 @@ pub struct GitRemoteSummary {
pub fetch_url: String, 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))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct GitRepositorySummary { pub struct GitRepositorySummary {
@@ -1153,7 +1266,7 @@ pub struct GitRepositorySummary {
pub remotes: Vec<GitRemoteSummary>, pub remotes: Vec<GitRemoteSummary>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct RepositorySummary { pub struct RepositorySummary {
@@ -1162,6 +1275,7 @@ pub struct RepositorySummary {
pub provider: String, pub provider: String,
pub source: RepositorySource, pub source: RepositorySource,
#[cfg_attr(feature = "typescript", ts(type = "number"))] #[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_revision: u64,
pub source_fingerprint: String, pub source_fingerprint: String,
pub observed_status: RepositoryObservedStatus, pub observed_status: RepositoryObservedStatus,
@@ -1194,7 +1308,7 @@ pub struct GitCommitSummary {
pub refs: Vec<String>, pub refs: Vec<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct RepositoryListResponse { pub struct RepositoryListResponse {
@@ -1204,7 +1318,7 @@ pub struct RepositoryListResponse {
pub diagnostics: Vec<Diagnostic>, pub diagnostics: Vec<Diagnostic>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct RepositoryDetailResponse { pub struct RepositoryDetailResponse {
@@ -1282,7 +1396,7 @@ pub struct RepositoryLogResponse {
pub const TICKET_RELATIONS_QUERY_PATH: &str = "/tickets/relations/search"; pub const TICKET_RELATIONS_QUERY_PATH: &str = "/tickets/relations/search";
pub const TICKET_ORCHESTRATION_PLANS_QUERY_PATH: &str = "/tickets/orchestration-plans/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))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum DiagnosticSeverity { pub enum DiagnosticSeverity {
@@ -1291,7 +1405,7 @@ pub enum DiagnosticSeverity {
Error, Error,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct Diagnostic { 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"))] #[cfg(all(test, feature = "typescript"))]
mod typescript_tests { mod typescript_tests {
#[test] #[test]
+130 -8
View File
@@ -3074,6 +3074,56 @@ impl server_api::ServerApi for WorkspaceApi {
observation, observation,
}) })
} }
async fn repository_list(
&self,
workspace_id: String,
) -> std::result::Result<server_api::RepositoryListResponse, server_api::RepositoryApiError>
{
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<server_api::RepositoryDetailResponse, server_api::RepositoryApiError>
{
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<CreateWorkspaceRepositoryResponse, server_api::RepositoryApiError>
{
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 { fn build_inner_router(api: WorkspaceApi) -> Router {
@@ -3399,15 +3449,7 @@ fn build_inner_router(api: WorkspaceApi) -> Router {
delete(scoped_unlink_objective_ticket), delete(scoped_unlink_objective_ticket),
) )
.route("/api/repositories", get(list_repositories)) .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/repositories/{repository_key}", get(repository_detail))
.route(
"/api/w/{workspace_id}/repositories/{repository_key}",
get(scoped_repository_detail),
)
.route( .route(
"/api/w/{workspace_id}/repositories/{repository_key}/ssh-connection-test", "/api/w/{workspace_id}/repositories/{repository_key}/ssh-connection-test",
post(scoped_probe_repository_ssh_connection) post(scoped_probe_repository_ssh_connection)
@@ -18781,6 +18823,47 @@ impl ApiError {
fn with_diagnostics(error: Error, diagnostics: Vec<RuntimeDiagnostic>) -> Self { fn with_diagnostics(error: Error, diagnostics: Vec<RuntimeDiagnostic>) -> Self {
Self { error, diagnostics } 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 { impl IntoResponse for ApiError {
@@ -22846,6 +22929,45 @@ mod tests {
.unwrap(); .unwrap();
assert!(String::from_utf8_lossy(&listed_body).contains("documentation")); 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!( let identity_uri = format!(
"/api/w/{}/settings/signing-identity", "/api/w/{}/settings/signing-identity",
workspace.workspace.workspace_id workspace.workspace.workspace_id
+495
View File
@@ -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"
}
}
}
}
}
}