feat: migrate Repository REST to generated Server API

This commit is contained in:
2026-09-18 19:30:32 +09:00
12 changed files with 2310 additions and 209 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",
+254 -15
View File
@@ -104,7 +104,13 @@ 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>,
additional_error_statuses: Vec<LitInt>,
bearer_auth: Option<syn::LitBool>,
browser_auth: Option<syn::LitBool>,
normalize_body_errors: Option<syn::LitBool>,
openapi: Option<syn::LitBool>,
} }
impl Parse for RouteArgs { impl Parse for RouteArgs {
@@ -114,7 +120,13 @@ 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,
additional_error_statuses: Vec::new(),
bearer_auth: None,
browser_auth: None,
normalize_body_errors: None,
openapi: None,
}; };
while !input.is_empty() { while !input.is_empty() {
@@ -132,12 +144,58 @@ 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",
)?, )?,
"additional_error_statuses" => {
if !result.additional_error_statuses.is_empty() {
return Err(syn::Error::new(
key.span(),
"duplicate `additional_error_statuses` route option",
));
}
let content;
syn::bracketed!(content in input);
while !content.is_empty() {
result.additional_error_statuses.push(content.parse()?);
if !content.is_empty() {
content.parse::<Token![,]>()?;
}
}
}
"bearer_auth" => set_once(
&mut result.bearer_auth,
input.parse::<syn::LitBool>()?,
&key,
"bearer_auth",
)?,
"browser_auth" => set_once(
&mut result.browser_auth,
input.parse::<syn::LitBool>()?,
&key,
"browser_auth",
)?,
"normalize_body_errors" => set_once(
&mut result.normalize_body_errors,
input.parse::<syn::LitBool>()?,
&key,
"normalize_body_errors",
)?,
"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 +225,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 +237,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 +263,13 @@ 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>,
additional_error_statuses: Vec<u16>,
bearer_auth: bool,
browser_auth: bool,
normalize_body_errors: bool,
openapi_skip: bool,
} }
struct ApiDefinition { struct ApiDefinition {
@@ -392,6 +460,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(
@@ -408,6 +491,34 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result<Operation> {
)); ));
} }
let error_status = error_body.as_ref().map(|_| error_status.unwrap_or(400)); let error_status = error_body.as_ref().map(|_| error_status.unwrap_or(400));
let mut additional_error_statuses = Vec::new();
for status in &route.additional_error_statuses {
let value = status.base10_parse::<u16>().map_err(|_| {
syn::Error::new(
status.span(),
"HTTP status must be an unsigned 16-bit integer",
)
})?;
if !(400..=599).contains(&value) {
return Err(syn::Error::new(
status.span(),
"additional error statuses must be between 400 and 599",
));
}
if error_status == Some(value) || additional_error_statuses.contains(&value) {
return Err(syn::Error::new(
status.span(),
"additional error statuses must be distinct",
));
}
additional_error_statuses.push(value);
}
if !additional_error_statuses.is_empty() && error_body.is_none() {
return Err(syn::Error::new_spanned(
&route.path,
"additional_error_statuses require a public error response type",
));
}
if http_method == Method::Head && response_body.is_some() { if http_method == Method::Head && response_body.is_some() {
return Err(syn::Error::new_spanned( return Err(syn::Error::new_spanned(
@@ -464,11 +575,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 +657,16 @@ 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,
additional_error_statuses,
bearer_auth: route.bearer_auth.as_ref().is_some_and(|value| value.value),
browser_auth: route.browser_auth.as_ref().is_some_and(|value| value.value),
normalize_body_errors: route
.normalize_body_errors
.as_ref()
.is_some_and(|value| value.value),
openapi_skip: route.openapi.as_ref().is_some_and(|value| !value.value),
}) })
} }
@@ -588,6 +717,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 +1011,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 +1061,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 +1122,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,17 +1318,69 @@ fn axum_adapter_tokens(
}; };
}) })
}); });
let body_extractor = operation.parameters.iter().find_map(|parameter| { let extension_extractors = operation.parameters.iter().filter_map(|parameter| {
if !matches!(parameter.location, Location::Body) { if !matches!(parameter.location, Location::Extension) {
return None; return None;
} }
let ident = &parameter.rust_ident; let ident = &parameter.rust_ident;
let ty = &parameter.ty; let ty = &parameter.ty;
Some(quote!(#api_crate::axum::framework::Json(#ident): #api_crate::axum::framework::Json<#ty>,)) Some(quote!(#api_crate::axum::framework::Extension(#ident): #api_crate::axum::framework::Extension<#ty>,))
}); });
let (body_extractor, body_rejection) = operation
.parameters
.iter()
.find(|parameter| matches!(parameter.location, Location::Body))
.map(|parameter| {
let ident = &parameter.rust_ident;
let ty = &parameter.ty;
if !operation.normalize_body_errors {
return (
Some(quote!(#api_crate::axum::framework::Json(#ident): #api_crate::axum::framework::Json<#ty>,)),
None,
);
}
let error = operation
.error_body
.as_ref()
.expect("body operations require an error type");
(
Some(quote!(
__body: ::core::result::Result<
#api_crate::axum::framework::Json<#ty>,
#api_crate::axum::framework::JsonRejection,
>,
)),
Some(quote! {
let #ident = match __body {
::core::result::Result::Ok(#api_crate::axum::framework::Json(value)) => value,
::core::result::Result::Err(rejection) => {
let status = rejection.status().as_u16();
let error = <#error as #api_crate::HttpRequestError>::from_request_rejection(
status,
rejection.body_text(),
);
return #api_crate::axum::json_response(
#api_crate::axum::status(status),
error,
);
}
};
}),
)
})
.unwrap_or((None, None));
let call_arguments = operation.parameters.iter().map(|parameter| &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,12 +1421,14 @@ 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
S: super::#trait_ident + Send + Sync + 'static, S: super::#trait_ident + Send + Sync + 'static,
{ {
#(#header_parsers)* #(#header_parsers)*
#body_rejection
#mapped #mapped
} }
} }
@@ -1285,12 +1476,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 +1494,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 +1514,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())
@@ -1327,14 +1532,39 @@ fn openapi_adapter_tokens(
operation.response::<#ty>(#status, "application/json", "Error response")?; operation.response::<#ty>(#status, "application/json", "Error response")?;
} }
}); });
let additional_errors = operation
.error_body
.as_ref()
.map(|ty| {
operation
.additional_error_statuses
.iter()
.map(|status| {
quote! {
operation.response::<#ty>(#status, "application/json", "Error response")?;
}
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
let bearer_auth = operation.bearer_auth.then(|| {
quote! { operation.bearer_authentication(); }
});
let browser_auth = operation.browser_auth.then(|| {
quote! { operation.browser_authentication(); }
});
quote! { quote! {
{ {
let mut operation = builder.operation(#method, #path, #operation_id)?; let mut operation = builder.operation(#method, #path, #operation_id)?;
#(#parameters)* #(#parameters)*
#bearer_auth
#browser_auth
#request #request
#response #response
#alternate_response
#error #error
#(#additional_errors)*
operation.finish()?; operation.finish()?;
} }
} }
@@ -1383,7 +1613,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 +1708,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, rejection::JsonRejection},
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},
+23 -3
View File
@@ -38,13 +38,21 @@
//! `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`;
//! `additional_error_statuses` publishes the same typed error schema for other declared outcomes.
//! `bearer_auth = true` and `browser_auth = true` attach standard bearer and browser-session cookie
//! security schemes. Body operations may opt into typed Axum rejection normalization with
//! typed Axum rejection normalization with `normalize_body_errors = true` and [`HttpRequestError`].
//! //!
//! Arguments are classified with `#[body]`, `#[query]`, `#[header]`, 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 +208,18 @@ 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;
}
/// Public error type capable of normalizing Axum request-body rejections.
pub trait HttpRequestError: HttpError {
fn from_request_rejection(status: u16, message: String) -> Self;
}
/// Marker used when an operation has no request, response, or public error body. /// 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 {}
+47 -15
View File
@@ -131,6 +131,7 @@ pub struct OpenApiBuilder {
schemas: BTreeMap<String, Value>, schemas: BTreeMap<String, Value>,
operation_ids: BTreeSet<String>, operation_ids: BTreeSet<String>,
uses_bearer_auth: bool, uses_bearer_auth: bool,
uses_browser_auth: bool,
} }
impl OpenApiBuilder { impl OpenApiBuilder {
@@ -157,6 +158,7 @@ impl OpenApiBuilder {
schemas: BTreeMap::new(), schemas: BTreeMap::new(),
operation_ids: BTreeSet::new(), operation_ids: BTreeSet::new(),
uses_bearer_auth: false, uses_bearer_auth: false,
uses_browser_auth: false,
}) })
} }
@@ -199,6 +201,7 @@ impl OpenApiBuilder {
request_body: None, request_body: None,
responses: BTreeMap::new(), responses: BTreeMap::new(),
bearer_security_required: None, bearer_security_required: None,
browser_security_required: false,
}) })
} }
@@ -214,15 +217,30 @@ impl OpenApiBuilder {
let schemas = self.schemas.into_iter().collect::<Map<_, _>>(); let schemas = self.schemas.into_iter().collect::<Map<_, _>>();
let mut components = Map::new(); let mut components = Map::new();
components.insert("schemas".to_owned(), Value::Object(schemas)); components.insert("schemas".to_owned(), Value::Object(schemas));
let mut security_schemes = Map::new();
if self.uses_bearer_auth { if self.uses_bearer_auth {
security_schemes.insert(
"bearerAuth".to_owned(),
json!({
"type": "http",
"scheme": "bearer"
}),
);
}
if self.uses_browser_auth {
security_schemes.insert(
"browserSession".to_owned(),
json!({
"type": "apiKey",
"in": "cookie",
"name": "yoi_workspace_session"
}),
);
}
if !security_schemes.is_empty() {
components.insert( components.insert(
"securitySchemes".to_owned(), "securitySchemes".to_owned(),
json!({ Value::Object(security_schemes),
"bearerAuth": {
"type": "http",
"scheme": "bearer"
}
}),
); );
} }
@@ -295,6 +313,7 @@ pub struct OpenApiOperation<'a> {
request_body: Option<Value>, request_body: Option<Value>,
responses: BTreeMap<String, Value>, responses: BTreeMap<String, Value>,
bearer_security_required: Option<bool>, bearer_security_required: Option<bool>,
browser_security_required: bool,
} }
impl OpenApiOperation<'_> { impl OpenApiOperation<'_> {
@@ -338,6 +357,16 @@ impl OpenApiOperation<'_> {
Ok(()) Ok(())
} }
pub fn bearer_authentication(&mut self) {
self.parent.uses_bearer_auth = true;
self.bearer_security_required = Some(true);
}
pub fn browser_authentication(&mut self) {
self.parent.uses_browser_auth = true;
self.browser_security_required = true;
}
pub fn request_body<T: OpenApiSchema>( pub fn request_body<T: OpenApiSchema>(
&mut self, &mut self,
content_type: &'static str, content_type: &'static str,
@@ -418,15 +447,18 @@ impl OpenApiOperation<'_> {
if let Some(request_body) = self.request_body { if let Some(request_body) = self.request_body {
operation.insert("requestBody".to_owned(), request_body); operation.insert("requestBody".to_owned(), request_body);
} }
if let Some(required) = self.bearer_security_required { let mut security = Vec::new();
operation.insert( if self.bearer_security_required == Some(false) {
"security".to_owned(), security.push(json!({}));
if required { }
json!([{ "bearerAuth": [] }]) if self.bearer_security_required.is_some() {
} else { security.push(json!({ "bearerAuth": [] }));
json!([{}, { "bearerAuth": [] }]) }
}, if self.browser_security_required {
); security.push(json!({ "browserSession": [] }));
}
if !security.is_empty() {
operation.insert("security".to_owned(), Value::Array(security));
} }
self.parent self.parent
.paths .paths
+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,
+205 -8
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,62 @@ 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)
}
}
fn repository_client_error(
backend: &BackendApiClient,
error: server_api::client_support::ClientError<server_api::RepositoryApiError>,
) -> BackendWorkspaceClientError {
let status = match &error {
server_api::client_support::ClientError::Public { status, .. } => Some(*status),
server_api::client_support::ClientError::Failure(_) => None,
};
match status {
Some(reqwest::StatusCode::UNAUTHORIZED) => {
BackendWorkspaceClientError::Api(BackendApiClientError::Unauthorized {
origin: backend.origin().clone(),
})
}
Some(reqwest::StatusCode::FORBIDDEN) => {
BackendWorkspaceClientError::Api(BackendApiClientError::Forbidden {
origin: backend.origin().clone(),
})
}
_ => BackendWorkspaceClientError::RepositoryApi(error),
}
}
#[derive(Clone, Debug)]
struct RepositoryBearerAuthorizer {
authorization: String,
}
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 +132,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 +158,19 @@ 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())
.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 let response = client
.blocking_request(Method::GET, &format!("/api/w/{workspace_id}/repositories"))? .repository_list(workspace_id.to_owned())
.send()?; .await
client.check_status(response.status())?; .map_err(|error| repository_client_error(backend, error))?;
let response = response.json::<RepositoryListResponse>()?;
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(),
@@ -170,6 +249,124 @@ mod tests {
handle.join().unwrap(); handle.join().unwrap();
} }
#[tokio::test]
async fn repository_catalog_uses_generated_path_and_bearer_authorizer() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut request = vec![0; 4096];
let read = stream.read(&mut request).unwrap();
let request = String::from_utf8_lossy(&request[..read]).to_ascii_lowercase();
assert!(request.starts_with("get /api/w/workspace-test/repositories "));
assert!(request.contains("authorization: bearer repository-secret\r\n"));
let body = r#"{"workspace_id":"workspace-test","items":[],"source":"workspace","diagnostics":[]}"#;
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
)
.unwrap();
});
let client =
BackendApiClient::from_access_token_for_test(&base_url, "repository-secret").unwrap();
assert!(
list_backend_workspace_repositories_with_client(&client, "workspace-test")
.await
.unwrap()
.is_empty()
);
handle.join().unwrap();
}
#[tokio::test]
async fn repository_catalog_enforces_generated_response_limit() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut request = vec![0; 4096];
let _ = stream.read(&mut request).unwrap();
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
REPOSITORY_RESPONSE_LIMIT + 1,
)
.unwrap();
stream.flush().unwrap();
let chunk = [b' '; 8192];
let mut remaining = REPOSITORY_RESPONSE_LIMIT + 1;
while remaining > 0 {
let length = remaining.min(chunk.len());
if stream.write_all(&chunk[..length]).is_err() {
break;
}
remaining -= length;
}
});
let client =
BackendApiClient::from_access_token_for_test(&base_url, "repository-secret").unwrap();
let error = list_backend_workspace_repositories_with_client(&client, "workspace-test")
.await
.unwrap_err();
assert!(
matches!(
&error,
BackendWorkspaceClientError::RepositoryApi(
server_api::client_support::ClientError::Failure(
server_api::client_support::ClientFailure::ResponseTooLarge {
limit: REPOSITORY_RESPONSE_LIMIT
}
)
)
),
"unexpected error: {error:?}"
);
handle.join().unwrap();
}
#[test]
fn repository_client_preserves_authentication_error_taxonomy() {
let client =
BackendApiClient::from_access_token_for_test("https://backend.example.test", "token")
.unwrap();
let unauthorized = repository_client_error(
&client,
server_api::client_support::ClientError::public(
reqwest::StatusCode::UNAUTHORIZED,
server_api::RepositoryApiError::new(
401,
"Unauthorized",
"authentication required",
Vec::new(),
),
),
);
assert!(matches!(
unauthorized,
BackendWorkspaceClientError::Api(BackendApiClientError::Unauthorized { .. })
));
let forbidden = repository_client_error(
&client,
server_api::client_support::ClientError::public(
reqwest::StatusCode::FORBIDDEN,
server_api::RepositoryApiError::new(
403,
"Forbidden",
"repository access denied",
Vec::new(),
),
),
);
assert!(matches!(
forbidden,
BackendWorkspaceClientError::Api(BackendApiClientError::Forbidden { .. })
));
}
#[test] #[test]
fn create_request_keeps_operation_key_for_exact_retry() { fn create_request_keeps_operation_key_for_exact_retry() {
let request = CreateBackendWorkspaceRequest { let request = CreateBackendWorkspaceRequest {
+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,54 @@
use std::{env, fs, io, path::PathBuf};
const DEFAULT_OUTPUT: &str = "openapi/server-api.json";
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut arguments = env::args_os().skip(1);
let first = arguments.next();
let (mode, output) = match first.as_deref().and_then(|value| value.to_str()) {
Some("--check") => ("check", arguments.next().map(PathBuf::from)),
Some("--stdout") => ("stdout", None),
Some(_) => ("write", first.map(PathBuf::from)),
None => ("write", None),
};
if arguments.next().is_some() {
return Err("usage: export_openapi [--check [PATH] | --stdout | PATH]".into());
}
let output = output.unwrap_or_else(|| PathBuf::from(DEFAULT_OUTPUT));
let json = server_api::canonical_openapi_document()?.to_json()?;
match mode {
"stdout" => print!("{json}"),
"check" => {
let checked_in = fs::read_to_string(&output)?;
if checked_in != json {
return Err(format!(
"{} is stale; regenerate with `cargo run -p server-api --example export_openapi -- {}`",
output.display(),
output.display()
)
.into());
}
println!("{} is current", output.display());
}
"write" => {
if let Some(parent) = output.parent() {
fs::create_dir_all(parent)?;
}
let mut temporary = output.clone();
let extension = output
.extension()
.and_then(|value| value.to_str())
.map_or_else(|| "tmp".to_owned(), |value| format!("{value}.tmp"));
temporary.set_extension(extension);
fs::write(&temporary, json)?;
fs::rename(&temporary, &output).or_else(|error| {
let _ = fs::remove_file(&temporary);
Err::<(), io::Error>(error)
})?;
println!("wrote {}", output.display());
}
_ => unreachable!(),
}
Ok(())
}
+294 -18
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,69 @@ 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
}
}
impl api_macros::HttpRequestError for RepositoryApiError {
fn from_request_rejection(status: u16, message: String) -> Self {
let reason = match status {
413 => "Payload Too Large",
415 => "Unsupported Media Type",
422 => "Unprocessable Entity",
_ => "Bad Request",
};
Self::new(status, reason, message, Vec::new())
}
}
macro_rules! impl_openapi_schema {
($($ty:ty),+ $(,)?) => {
$(impl api_macros::openapi::OpenApiSchema for $ty {})+
};
}
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 +89,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 +103,104 @@ 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,
additional_error_statuses = [401, 403, 404, 500],
bearer_auth = true,
browser_auth = true
)]
async fn repository_list(
&self,
#[path] workspace_id: String,
) -> Result<RepositoryListResponse, RepositoryApiError>;
#[get(
"/api/repositories",
status = 200,
error_status = 400,
additional_error_statuses = [401, 403, 404, 500],
bearer_auth = true,
browser_auth = true
)]
async fn repository_list_alias(&self) -> Result<RepositoryListResponse, RepositoryApiError>;
#[get(
"/api/w/{workspace_id}/repositories/{repository_key}",
status = 200,
error_status = 404,
additional_error_statuses = [400, 401, 403, 500],
bearer_auth = true,
browser_auth = true
)]
async fn repository_detail(
&self,
#[path] workspace_id: String,
#[path] repository_key: String,
) -> Result<RepositoryDetailResponse, RepositoryApiError>;
#[get(
"/api/repositories/{repository_key}",
status = 200,
error_status = 404,
additional_error_statuses = [400, 401, 403, 500],
bearer_auth = true,
browser_auth = true
)]
async fn repository_detail_alias(
&self,
#[path] repository_key: String,
) -> Result<RepositoryDetailResponse, RepositoryApiError>;
#[post(
"/api/w/{workspace_id}/repositories",
status = 201,
alternate_status = 200,
error_status = 400,
additional_error_statuses = [401, 403, 404, 409, 413, 415, 422, 500],
bearer_auth = true,
browser_auth = true,
normalize_body_errors = true
)]
async fn repository_create(
&self,
#[extension] actor: RequestActor,
#[path] workspace_id: String,
#[body] request: CreateWorkspaceRepositoryRequest,
) -> Result<CreateWorkspaceRepositoryResponse, RepositoryApiError>;
}
/// Digest of the fully rendered canonical contract with its digest slot normalized.
pub fn canonical_openapi_source_digest() -> Result<String, api_macros::openapi::OpenApiError> {
const NORMALIZED_DIGEST: &str =
"sha256:0000000000000000000000000000000000000000000000000000000000000000";
let normalized = server_api_openapi(api_macros::openapi::OpenApiInfo {
title: "Yoi Server API",
version: env!("CARGO_PKG_VERSION"),
source_digest: NORMALIZED_DIGEST,
})?
.to_json()?;
let digest = Sha256::digest(normalized.as_bytes());
let mut encoded = String::with_capacity("sha256:".len() + digest.len() * 2);
encoded.push_str("sha256:");
for byte in digest {
use std::fmt::Write as _;
write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail");
}
Ok(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 +558,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 +615,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 +628,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 +637,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 +1295,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 +1304,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 +1312,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 +1323,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 +1332,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 +1365,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 +1375,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 +1453,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 +1462,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 {
@@ -3645,10 +3816,14 @@ mod tests {
#[test] #[test]
fn worker_session_contract_and_flattened_availability_are_stable() { fn worker_session_contract_and_flattened_availability_are_stable() {
let operations = ServerApiMetadata::OPERATIONS; let operations = ServerApiMetadata::OPERATIONS;
assert_eq!(operations.len(), 1); assert_eq!(operations.len(), 6);
assert_eq!(operations[0].method, HttpMethod::Get); let operation = operations
.iter()
.find(|operation| operation.operation_id == "worker_session")
.expect("worker-session operation must remain in ServerApi metadata");
assert_eq!(operation.method, HttpMethod::Get);
assert_eq!( assert_eq!(
operations[0].path, operation.path,
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/session" "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/session"
); );
@@ -4985,6 +5160,107 @@ 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/repositories"]["get"].is_object());
assert!(value["paths"]["/api/repositories/{repository_key}"]["get"].is_object());
for (path, method) in [
("/api/w/{workspace_id}/repositories", "get"),
("/api/w/{workspace_id}/repositories", "post"),
("/api/w/{workspace_id}/repositories/{repository_key}", "get"),
("/api/repositories", "get"),
("/api/repositories/{repository_key}", "get"),
] {
let operation = &value["paths"][path][method];
assert_eq!(
operation["security"][0]["bearerAuth"],
serde_json::json!([])
);
assert_eq!(
operation["security"][1]["browserSession"],
serde_json::json!([])
);
for status in ["401", "403", "500"] {
assert!(
operation["responses"][status].is_object(),
"missing {method} {path} response {status}"
);
}
}
let repository_collection = &value["paths"]["/api/w/{workspace_id}/repositories"];
let repository_create = &repository_collection["post"];
assert_eq!(
repository_create["security"][0]["bearerAuth"],
serde_json::json!([])
);
assert_eq!(
repository_create["security"][1]["browserSession"],
serde_json::json!([])
);
for status in [
"200", "201", "400", "401", "403", "404", "409", "413", "415", "422", "500",
] {
assert!(
repository_create["responses"][status].is_object(),
"missing repository-create response {status}"
);
}
assert_eq!(
repository_create["responses"]["422"]["content"]["application/json"]["schema"]["$ref"],
"#/components/schemas/RepositoryApiError"
);
assert_eq!(
value["components"]["securitySchemes"]["bearerAuth"]["scheme"],
"bearer"
);
assert_eq!(
value["components"]["securitySchemes"]["browserSession"]["in"],
"cookie"
);
assert!(value["paths"]["/api/w/{workspace_id}/repositories/{repository_key}"]
["get"]["responses"]["404"]
.is_object());
}
}
#[cfg(all(test, feature = "typescript"))] #[cfg(all(test, feature = "typescript"))]
mod typescript_tests { mod typescript_tests {
#[test] #[test]
+571 -148
View File
@@ -1629,11 +1629,31 @@ fn signed_request_target(uri: &Uri) -> &str {
.unwrap_or_else(|| uri.path()) .unwrap_or_else(|| uri.path())
} }
fn repository_api_rejection(path: &str, status: StatusCode, message: &str) -> Response {
if path == "/api/repositories"
|| path.starts_with("/api/repositories/")
|| (path.starts_with("/api/w/") && path.contains("/repositories"))
{
return (
status,
Json(server_api::RepositoryApiError::new(
status.as_u16(),
status.canonical_reason().unwrap_or("error"),
message,
Vec::new(),
)),
)
.into_response();
}
status.into_response()
}
async fn authorize_scoped_workspace_request( async fn authorize_scoped_workspace_request(
api: &WorkspaceServerApi, api: &WorkspaceServerApi,
workspace_id: &str, workspace_id: &str,
request: &mut Request, request: &mut Request,
) -> std::result::Result<(), Response> { ) -> std::result::Result<(), Response> {
let request_path = request.uri().path().to_owned();
let proof = request let proof = request
.headers() .headers()
.get(worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER) .get(worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER)
@@ -1645,7 +1665,13 @@ async fn authorize_scoped_workspace_request(
let body = std::mem::take(request.body_mut()); let body = std::mem::take(request.body_mut());
let body = axum::body::to_bytes(body, 16 * 1024 * 1024) let body = axum::body::to_bytes(body, 16 * 1024 * 1024)
.await .await
.map_err(|_| StatusCode::BAD_REQUEST.into_response())?; .map_err(|_| {
repository_api_rejection(
&request_path,
StatusCode::BAD_REQUEST,
"invalid request body",
)
})?;
let digest = worker_runtime::auth::request_body_digest(&body); let digest = worker_runtime::auth::request_body_digest(&body);
*request.body_mut() = axum::body::Body::from(body); *request.body_mut() = axum::body::Body::from(body);
let permission = if path let permission = if path
@@ -1675,7 +1701,13 @@ async fn authorize_scoped_workspace_request(
&digest, &digest,
) )
.await .await
.map_err(|_| StatusCode::UNAUTHORIZED.into_response())?; .map_err(|_| {
repository_api_rejection(
&request_path,
StatusCode::UNAUTHORIZED,
"invalid runtime request proof",
)
})?;
request.extensions_mut().insert(source); request.extensions_mut().insert(source);
if !matches!( if !matches!(
*request.method(), *request.method(),
@@ -1687,11 +1719,11 @@ async fn authorize_scoped_workspace_request(
.map_err(server_error_response)? .map_err(server_error_response)?
.is_some_and(|workspace| workspace.state == "active") .is_some_and(|workspace| workspace.state == "active")
{ {
return Err(( return Err(repository_api_rejection(
&request_path,
StatusCode::CONFLICT, StatusCode::CONFLICT,
"Workspace is deleting and no longer accepts mutations", "Workspace is deleting and no longer accepts mutations",
) ));
.into_response());
} }
return Ok(()); return Ok(());
} }
@@ -1699,7 +1731,13 @@ async fn authorize_scoped_workspace_request(
let actor = resolve_server_actor(api, request.headers()) let actor = resolve_server_actor(api, request.headers())
.await .await
.map_err(server_error_response)? .map_err(server_error_response)?
.ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?; .ok_or_else(|| {
repository_api_rejection(
&request_path,
StatusCode::UNAUTHORIZED,
"authentication required",
)
})?;
let cookie_authenticated = matches!(actor.auth_method, ActorAuthMethod::BrowserSession); let cookie_authenticated = matches!(actor.auth_method, ActorAuthMethod::BrowserSession);
let mutating = !matches!( let mutating = !matches!(
@@ -1715,7 +1753,11 @@ async fn authorize_scoped_workspace_request(
.and_then(|value| value.to_str().ok()) .and_then(|value| value.to_str().ok())
.unwrap_or_default() .unwrap_or_default()
{ {
return Err(StatusCode::FORBIDDEN.into_response()); return Err(repository_api_rejection(
&request_path,
StatusCode::FORBIDDEN,
"cross-origin browser mutation denied",
));
} }
} }
request.extensions_mut().insert(actor); request.extensions_mut().insert(actor);
@@ -1727,11 +1769,11 @@ async fn authorize_scoped_workspace_request(
.map_err(server_error_response)? .map_err(server_error_response)?
.is_some_and(|workspace| workspace.state == "active") .is_some_and(|workspace| workspace.state == "active")
{ {
return Err(( return Err(repository_api_rejection(
&request_path,
StatusCode::CONFLICT, StatusCode::CONFLICT,
"Workspace is deleting and no longer accepts mutations", "Workspace is deleting and no longer accepts mutations",
) ));
.into_response());
} }
Ok(()) Ok(())
} }
@@ -1744,6 +1786,7 @@ async fn authorize_workspace_api_request(
if !request.uri().path().starts_with("/api/") { if !request.uri().path().starts_with("/api/") {
return next.run(request).await; return next.run(request).await;
} }
let request_path = request.uri().path().to_owned();
let public_server_api = is_server_global_forward(request.uri().path()); let public_server_api = is_server_global_forward(request.uri().path());
let workspace_id = api.workspace_id().to_owned(); let workspace_id = api.workspace_id().to_owned();
let proof = request let proof = request
@@ -1756,7 +1799,11 @@ async fn authorize_workspace_api_request(
let path = signed_request_target(request.uri()).to_owned(); let path = signed_request_target(request.uri()).to_owned();
let body = std::mem::take(request.body_mut()); let body = std::mem::take(request.body_mut());
let Ok(body) = axum::body::to_bytes(body, 16 * 1024 * 1024).await else { let Ok(body) = axum::body::to_bytes(body, 16 * 1024 * 1024).await else {
return StatusCode::BAD_REQUEST.into_response(); return repository_api_rejection(
&request_path,
StatusCode::BAD_REQUEST,
"invalid request body",
);
}; };
let digest = worker_runtime::auth::request_body_digest(&body); let digest = worker_runtime::auth::request_body_digest(&body);
*request.body_mut() = axum::body::Body::from(body); *request.body_mut() = axum::body::Body::from(body);
@@ -1787,7 +1834,11 @@ async fn authorize_workspace_api_request(
) )
.await .await
else { else {
return StatusCode::UNAUTHORIZED.into_response(); return repository_api_rejection(
&request_path,
StatusCode::UNAUTHORIZED,
"invalid runtime request proof",
);
}; };
request.extensions_mut().insert(source); request.extensions_mut().insert(source);
if !matches!( if !matches!(
@@ -1801,11 +1852,11 @@ async fn authorize_workspace_api_request(
.flatten() .flatten()
.is_some_and(|workspace| workspace.state == "active") .is_some_and(|workspace| workspace.state == "active")
{ {
return ( return repository_api_rejection(
&request_path,
StatusCode::CONFLICT, StatusCode::CONFLICT,
"Workspace is deleting and no longer accepts mutations", "Workspace is deleting and no longer accepts mutations",
) );
.into_response();
} }
return next.run(request).await; return next.run(request).await;
} }
@@ -1820,7 +1871,13 @@ async fn authorize_workspace_api_request(
{ {
Ok(Some(actor)) => actor, Ok(Some(actor)) => actor,
Ok(None) if public_server_api => return next.run(request).await, Ok(None) if public_server_api => return next.run(request).await,
Ok(None) | Err(_) => return StatusCode::UNAUTHORIZED.into_response(), Ok(None) | Err(_) => {
return repository_api_rejection(
&request_path,
StatusCode::UNAUTHORIZED,
"authentication required",
);
}
}; };
let cookie_authenticated = matches!(actor.auth_method, ActorAuthMethod::BrowserSession); let cookie_authenticated = matches!(actor.auth_method, ActorAuthMethod::BrowserSession);
let mutating = !matches!( let mutating = !matches!(
@@ -1836,7 +1893,11 @@ async fn authorize_workspace_api_request(
.and_then(|value| value.to_str().ok()) .and_then(|value| value.to_str().ok())
.unwrap_or_default() .unwrap_or_default()
{ {
return StatusCode::FORBIDDEN.into_response(); return repository_api_rejection(
&request_path,
StatusCode::FORBIDDEN,
"cross-origin browser mutation denied",
);
} }
} }
request.extensions_mut().insert(actor); request.extensions_mut().insert(actor);
@@ -1849,11 +1910,11 @@ async fn authorize_workspace_api_request(
.flatten() .flatten()
.is_some_and(|workspace| workspace.state == "active") .is_some_and(|workspace| workspace.state == "active")
{ {
return ( return repository_api_rejection(
&request_path,
StatusCode::CONFLICT, StatusCode::CONFLICT,
"Workspace is deleting and no longer accepts mutations", "Workspace is deleting and no longer accepts mutations",
) );
.into_response();
} }
next.run(request).await next.run(request).await
} }
@@ -1964,7 +2025,7 @@ async fn dispatch_workspace_request(
} }
}; };
let Some(router) = router else { let Some(router) = router else {
return StatusCode::NOT_FOUND.into_response(); return repository_api_rejection(&path, StatusCode::NOT_FOUND, "workspace was not found");
}; };
match router.oneshot(request).await { match router.oneshot(request).await {
Ok(response) => response, Ok(response) => response,
@@ -3133,6 +3194,77 @@ 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_list_alias(
&self,
) -> std::result::Result<server_api::RepositoryListResponse, server_api::RepositoryApiError>
{
list_repositories(State(self.clone()))
.await
.map(|Json(response)| response)
.map_err(ApiError::into_repository_api_error)
}
async fn repository_detail(
&self,
workspace_id: String,
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_detail_alias(
&self,
repository_key: String,
) -> std::result::Result<server_api::RepositoryDetailResponse, server_api::RepositoryApiError>
{
repository_detail(State(self.clone()), AxumPath(repository_key))
.await
.map(|Json(response)| response)
.map_err(ApiError::into_repository_api_error)
}
async fn repository_create(
&self,
actor: RequestActor,
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 {
@@ -3457,16 +3589,6 @@ fn build_inner_router(api: WorkspaceApi) -> Router {
"/api/w/{workspace_id}/objectives/{objective_id}/ticket-links/{ticket_id}", "/api/w/{workspace_id}/objectives/{objective_id}/ticket-links/{ticket_id}",
delete(scoped_unlink_objective_ticket), 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( .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)
@@ -18894,127 +19016,142 @@ 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 = api_error_status(&self.error);
let message = api_error_response_message(&self.error);
server_api::RepositoryApiError::new(
status.as_u16(),
status.canonical_reason().unwrap_or("error"),
message,
working_directory_diagnostics(self.diagnostics),
)
}
}
fn api_error_status(error: &Error) -> StatusCode {
match error {
Error::BrowserReopenConfirmationRequired | Error::WorkspacePermissionDenied(_) => {
StatusCode::FORBIDDEN
}
Error::TicketAssignmentConflict(_)
| Error::WorkdirAttachmentConflict(_)
| Error::WorkspaceConfigConflict(_)
| Error::RuntimeBindingConflict(_)
| Error::RuntimeBindingRevisionConflict { .. }
| Error::RuntimeBindingFingerprintConflict { .. }
| Error::RepositoryConflict(_) => StatusCode::CONFLICT,
Error::WorkerSourceIdentity(_) | Error::InvalidInput(_) => StatusCode::BAD_REQUEST,
Error::InvalidRuntimeIdentifier { .. } | Error::ReservedWorkerName(_) => {
StatusCode::BAD_REQUEST
}
Error::Ticket(ticket::TicketError::NotFound(_))
| Error::MergeRequest(merge_request::MergeRequestError::NotFound) => StatusCode::NOT_FOUND,
Error::MergeRequest(merge_request::MergeRequestError::Validation(_)) => {
StatusCode::BAD_REQUEST
}
Error::MergeRequest(_) => StatusCode::CONFLICT,
Error::Ticket(
ticket::TicketError::Ambiguous { .. }
| ticket::TicketError::Locked { .. }
| ticket::TicketError::Conflict(_),
) => StatusCode::CONFLICT,
Error::Ticket(
ticket::TicketError::InvalidPathComponent(_)
| ticket::TicketError::PathEscapesRoot { .. },
) => StatusCode::BAD_REQUEST,
Error::InvalidRecordId(_)
| Error::MissingFrontmatter(_)
| Error::UnknownHost(_)
| Error::UnknownRuntime(_)
| Error::UnknownWorker { .. }
| Error::UnknownRepository(_)
| Error::RuntimeBindingNotFound { .. }
| Error::WorkspaceIdMismatch => StatusCode::NOT_FOUND,
Error::RuntimeOperationFailed { code, .. } if code == "skill_not_found" => {
StatusCode::NOT_FOUND
}
Error::RuntimeCapabilityUnsupported { .. } => StatusCode::NOT_IMPLEMENTED,
Error::RuntimeOperationFailed { code, .. } if code == "repository_not_configured" => {
StatusCode::NOT_FOUND
}
Error::RuntimeOperationFailed { code, .. } if code == "repository_provider_unsupported" => {
StatusCode::BAD_REQUEST
}
Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_auth_failed" => {
StatusCode::UNAUTHORIZED
}
Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_timeout" => {
StatusCode::GATEWAY_TIMEOUT
}
Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_unsupported" => {
StatusCode::NOT_IMPLEMENTED
}
Error::RuntimeOperationFailed { code, .. }
if code == "profile_registry_revision_conflict"
|| code == "profile_source_revision_conflict"
|| code == "workspace_metadata_revision_conflict"
|| code == "workspace_cleanup_plan_stale"
|| code == "workspace_cleanup_worker_blocked"
|| code == "workspace_cleanup_workdir_blocked"
|| code == "workspace_cleanup_worker_pinned" =>
{
StatusCode::CONFLICT
}
Error::RuntimeOperationFailed { code, .. }
if code == "unknown_profile_source"
|| code == "unknown_profile_selector"
|| code == "unknown_objective" =>
{
StatusCode::NOT_FOUND
}
Error::RuntimeOperationFailed { code, .. }
if code == "workspace_display_name_invalid" || code.starts_with("profile_") =>
{
StatusCode::BAD_REQUEST
}
Error::RuntimeOperationFailed { code, .. } if code.ends_with("_blocked") => {
StatusCode::CONFLICT
}
Error::RuntimeOperationFailed { code, .. }
if code.starts_with("workspace_settings_")
|| code.starts_with("invalid_")
|| code.starts_with("unsupported_worker_profile")
|| code.starts_with("working_directory_")
|| code.starts_with("workspace_cleanup_")
|| code == "default_runtime_not_configured"
|| code == "workspace_worker_workdir_required"
|| code.ends_with("_already_exists")
|| code.ends_with("_not_config_managed")
|| code.ends_with("_unsupported") =>
{
StatusCode::BAD_REQUEST
}
Error::RuntimeOperationFailed { code, .. }
if code == "runtime_capacity_unavailable"
|| code == "runtime_workdir_capacity_unavailable" =>
{
StatusCode::SERVICE_UNAVAILABLE
}
Error::WorkspaceSigningIdentity { .. } => StatusCode::SERVICE_UNAVAILABLE,
Error::RuntimeOperationFailed { .. } => StatusCode::BAD_GATEWAY,
_ => StatusCode::INTERNAL_SERVER_ERROR,
}
}
fn api_error_response_message(error: &Error) -> String {
match error {
Error::RuntimeOperationFailed { code, message, .. } => {
format!("{code}: {}", sanitize_backend_error(message))
}
_ => sanitize_backend_error(&error.to_string()),
}
} }
impl IntoResponse for ApiError { impl IntoResponse for ApiError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
let status = match &self.error { let status = api_error_status(&self.error);
Error::BrowserReopenConfirmationRequired | Error::WorkspacePermissionDenied(_) => { let response_message = api_error_response_message(&self.error);
StatusCode::FORBIDDEN
}
Error::TicketAssignmentConflict(_)
| Error::WorkdirAttachmentConflict(_)
| Error::WorkspaceConfigConflict(_)
| Error::RuntimeBindingConflict(_)
| Error::RuntimeBindingRevisionConflict { .. }
| Error::RuntimeBindingFingerprintConflict { .. }
| Error::RepositoryConflict(_) => StatusCode::CONFLICT,
Error::WorkerSourceIdentity(_) | Error::InvalidInput(_) => StatusCode::BAD_REQUEST,
Error::InvalidRuntimeIdentifier { .. } | Error::ReservedWorkerName(_) => {
StatusCode::BAD_REQUEST
}
Error::Ticket(ticket::TicketError::NotFound(_))
| Error::MergeRequest(merge_request::MergeRequestError::NotFound) => {
StatusCode::NOT_FOUND
}
Error::MergeRequest(merge_request::MergeRequestError::Validation(_)) => {
StatusCode::BAD_REQUEST
}
Error::MergeRequest(_) => StatusCode::CONFLICT,
Error::Ticket(
ticket::TicketError::Ambiguous { .. }
| ticket::TicketError::Locked { .. }
| ticket::TicketError::Conflict(_),
) => StatusCode::CONFLICT,
Error::Ticket(
ticket::TicketError::InvalidPathComponent(_)
| ticket::TicketError::PathEscapesRoot { .. },
) => StatusCode::BAD_REQUEST,
Error::InvalidRecordId(_)
| Error::MissingFrontmatter(_)
| Error::UnknownHost(_)
| Error::UnknownRuntime(_)
| Error::UnknownWorker { .. }
| Error::UnknownRepository(_)
| Error::RuntimeBindingNotFound { .. }
| Error::WorkspaceIdMismatch => StatusCode::NOT_FOUND,
Error::RuntimeOperationFailed { code, .. } if code == "skill_not_found" => {
StatusCode::NOT_FOUND
}
Error::RuntimeCapabilityUnsupported { .. } => StatusCode::NOT_IMPLEMENTED,
Error::RuntimeOperationFailed { code, .. } if code == "repository_not_configured" => {
StatusCode::NOT_FOUND
}
Error::RuntimeOperationFailed { code, .. }
if code == "repository_provider_unsupported" =>
{
StatusCode::BAD_REQUEST
}
Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_auth_failed" => {
StatusCode::UNAUTHORIZED
}
Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_timeout" => {
StatusCode::GATEWAY_TIMEOUT
}
Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_unsupported" => {
StatusCode::NOT_IMPLEMENTED
}
Error::RuntimeOperationFailed { code, .. }
if code == "profile_registry_revision_conflict"
|| code == "profile_source_revision_conflict"
|| code == "workspace_metadata_revision_conflict"
|| code == "workspace_cleanup_plan_stale"
|| code == "workspace_cleanup_worker_blocked"
|| code == "workspace_cleanup_workdir_blocked"
|| code == "workspace_cleanup_worker_pinned" =>
{
StatusCode::CONFLICT
}
Error::RuntimeOperationFailed { code, .. }
if code == "unknown_profile_source"
|| code == "unknown_profile_selector"
|| code == "unknown_objective" =>
{
StatusCode::NOT_FOUND
}
Error::RuntimeOperationFailed { code, .. }
if code == "workspace_display_name_invalid" || code.starts_with("profile_") =>
{
StatusCode::BAD_REQUEST
}
Error::RuntimeOperationFailed { code, .. } if code.ends_with("_blocked") => {
StatusCode::CONFLICT
}
Error::RuntimeOperationFailed { code, .. }
if code.starts_with("workspace_settings_")
|| code.starts_with("invalid_")
|| code.starts_with("unsupported_worker_profile")
|| code.starts_with("working_directory_")
|| code.starts_with("workspace_cleanup_")
|| code == "default_runtime_not_configured"
|| code == "workspace_worker_workdir_required"
|| code.ends_with("_already_exists")
|| code.ends_with("_not_config_managed")
|| code.ends_with("_unsupported") =>
{
StatusCode::BAD_REQUEST
}
Error::RuntimeOperationFailed { code, .. }
if code == "runtime_capacity_unavailable"
|| code == "runtime_workdir_capacity_unavailable" =>
{
StatusCode::SERVICE_UNAVAILABLE
}
Error::WorkspaceSigningIdentity { .. } => StatusCode::SERVICE_UNAVAILABLE,
Error::RuntimeOperationFailed { .. } => StatusCode::BAD_GATEWAY,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
let response_message = match &self.error {
Error::RuntimeOperationFailed { code, message, .. } => {
format!("{code}: {}", sanitize_backend_error(message))
}
_ => sanitize_backend_error(&self.error.to_string()),
};
let log = ApiErrorLog { let log = ApiErrorLog {
kind: self kind: self
.diagnostics .diagnostics
@@ -19077,6 +19214,46 @@ mod tests {
SqliteWorkspaceStore, UserRecord, WorkspaceRecord, WorkspaceRuntimeBinding, SqliteWorkspaceStore, UserRecord, WorkspaceRecord, WorkspaceRuntimeBinding,
}; };
#[derive(Clone)]
struct TestBearerAuthorizer(&'static str);
impl server_api::client_support::RequestAuthorizer for TestBearerAuthorizer {
fn authorize(
&self,
_request: server_api::client_support::AuthorizerRequest<'_>,
) -> std::result::Result<
axum::http::HeaderMap,
server_api::client_support::AuthorizationError,
> {
let mut headers = axum::http::HeaderMap::new();
headers.insert(
axum::http::header::AUTHORIZATION,
format!("Bearer {}", self.0)
.parse()
.map_err(|_| server_api::client_support::AuthorizationError::new())?,
);
Ok(headers)
}
}
#[tokio::test]
async fn repository_conflict_rejections_use_the_typed_error_contract() {
let response = repository_api_rejection(
"/api/w/workspace-test/repositories",
StatusCode::CONFLICT,
"Workspace is deleting and no longer accepts mutations",
);
assert_eq!(response.status(), StatusCode::CONFLICT);
let body: server_api::RepositoryApiError =
serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap())
.unwrap();
assert_eq!(body.error, "Conflict");
assert_eq!(
body.message,
"Workspace is deleting and no longer accepts mutations"
);
}
#[test] #[test]
fn backend_resource_timeout_maps_to_gateway_timeout() { fn backend_resource_timeout_maps_to_gateway_timeout() {
assert_eq!( assert_eq!(
@@ -22871,6 +23048,122 @@ mod tests {
.body(Body::from(body.to_string())) .body(Body::from(body.to_string()))
.unwrap() .unwrap()
}; };
let anonymous_repository = app
.clone()
.oneshot(
Request::builder()
.uri(&repositories_uri)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(anonymous_repository.status(), StatusCode::UNAUTHORIZED);
let anonymous_body: Value = serde_json::from_slice(
&to_bytes(anonymous_repository.into_body(), usize::MAX)
.await
.unwrap(),
)
.unwrap();
assert_eq!(anonymous_body["error"], "Unauthorized");
let unknown_field = app
.clone()
.oneshot(create_repository(serde_json::json!({
"repository_key": "unknown-field",
"source": temp.path().join("unknown-field").display().to_string(),
"unexpected": true
})))
.await
.unwrap();
assert_eq!(unknown_field.status(), StatusCode::UNPROCESSABLE_ENTITY);
let unknown_field_body: Value = serde_json::from_slice(
&to_bytes(unknown_field.into_body(), usize::MAX)
.await
.unwrap(),
)
.unwrap();
assert_eq!(unknown_field_body["error"], "Unprocessable Entity");
let malformed = app
.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri(&repositories_uri)
.header(
axum::http::header::COOKIE,
"yoi_workspace_session=browser-session-auth",
)
.header(ORIGIN, &expected_origin)
.header(CONTENT_TYPE, "application/json")
.body(Body::from("{"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(malformed.status(), StatusCode::BAD_REQUEST);
assert!(
serde_json::from_slice::<server_api::RepositoryApiError>(
&to_bytes(malformed.into_body(), usize::MAX).await.unwrap()
)
.is_ok()
);
let unsupported_media_type = app
.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri(&repositories_uri)
.header(
axum::http::header::COOKIE,
"yoi_workspace_session=browser-session-auth",
)
.header(ORIGIN, &expected_origin)
.body(Body::from("{}"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
unsupported_media_type.status(),
StatusCode::UNSUPPORTED_MEDIA_TYPE
);
assert!(
serde_json::from_slice::<server_api::RepositoryApiError>(
&to_bytes(unsupported_media_type.into_body(), usize::MAX)
.await
.unwrap()
)
.is_ok()
);
let oversized = app
.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri(&repositories_uri)
.header(
axum::http::header::COOKIE,
"yoi_workspace_session=browser-session-auth",
)
.header(ORIGIN, &expected_origin)
.header(CONTENT_TYPE, "application/json")
.body(Body::from(vec![b' '; 5 * 1024 * 1024 + 1]))
.unwrap(),
)
.await
.unwrap();
assert_eq!(oversized.status(), StatusCode::PAYLOAD_TOO_LARGE);
assert!(
serde_json::from_slice::<server_api::RepositoryApiError>(
&to_bytes(oversized.into_body(), usize::MAX).await.unwrap()
)
.is_ok()
);
let created = app let created = app
.clone() .clone()
.oneshot(create_repository(repository_request.clone())) .oneshot(create_repository(repository_request.clone()))
@@ -22960,6 +23253,93 @@ 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 listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let api_base_url = format!("http://{}", listener.local_addr().unwrap());
let network_app = app.clone();
let server = tokio::spawn(async move {
axum::serve(listener, network_app).await.unwrap();
});
let generated = server_api::ServerApiClient::builder(&api_base_url)
.unwrap()
.authorizer(TestBearerAuthorizer("api-token-auth"))
.build()
.unwrap();
let generated_workspace_id = workspace.workspace.workspace_id.clone();
let generated_list = generated
.repository_list(generated_workspace_id.clone())
.await
.unwrap();
assert!(
generated_list
.items
.iter()
.any(|repository| repository.repository_key == "documentation")
);
assert_eq!(
generated
.repository_detail(generated_workspace_id.clone(), "documentation".to_owned())
.await
.unwrap()
.item
.repository_key,
"documentation"
);
let generated_request = CreateWorkspaceRepositoryRequest {
repository_key: "generated-client".to_owned(),
source: "/srv/repos/generated-client".to_owned(),
default_ref: Some("main".to_owned()),
};
let created = generated
.repository_create(generated_workspace_id.clone(), generated_request.clone())
.await
.unwrap();
assert!(!created.replayed);
let replayed = generated
.repository_create(generated_workspace_id, generated_request)
.await
.unwrap();
assert!(replayed.replayed);
server.abort();
let identity_uri = format!( let identity_uri = format!(
"/api/w/{}/settings/signing-identity", "/api/w/{}/settings/signing-identity",
workspace.workspace.workspace_id workspace.workspace.workspace_id
@@ -23128,6 +23508,7 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(mixed_auth_csrf_rejected.status(), StatusCode::FORBIDDEN); assert_eq!(mixed_auth_csrf_rejected.status(), StatusCode::FORBIDDEN);
let deleting_app = app.clone();
let csrf_accepted = app let csrf_accepted = app
.oneshot( .oneshot(
Request::builder() Request::builder()
@@ -23145,6 +23526,48 @@ mod tests {
.await .await
.unwrap(); .unwrap();
assert_ne!(csrf_accepted.status(), StatusCode::FORBIDDEN); assert_ne!(csrf_accepted.status(), StatusCode::FORBIDDEN);
let preflight = store
.workspace_deletion_preflight("account-auth", &workspace.workspace.workspace_id)
.unwrap();
store
.reserve_workspace_deletion(
"account-auth",
&workspace.workspace.workspace_id,
&server_api::WorkspaceDeletionRequest {
operation_id: "repository-conflict-test".to_owned(),
expected_revision: preflight.expected_revision,
confirmation: preflight.display_name,
},
)
.unwrap();
let deleting_conflict = deleting_app
.oneshot(
Request::builder()
.method(Method::POST)
.uri(&repositories_uri)
.header(axum::http::header::AUTHORIZATION, "Bearer api-token-auth")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::json!({
"repository_key": "blocked-by-deletion",
"source": temp.path().join("blocked-by-deletion").display().to_string()
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(deleting_conflict.status(), StatusCode::CONFLICT);
let deleting_body: server_api::RepositoryApiError = serde_json::from_slice(
&to_bytes(deleting_conflict.into_body(), usize::MAX)
.await
.unwrap(),
)
.unwrap();
assert_eq!(deleting_body.error, "Conflict");
assert!(deleting_body.message.contains("deleting"));
} }
#[tokio::test] #[tokio::test]
+852
View File
@@ -0,0 +1,852 @@
{
"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"
}
},
"securitySchemes": {
"bearerAuth": {
"scheme": "bearer",
"type": "http"
},
"browserSession": {
"in": "cookie",
"name": "yoi_workspace_session",
"type": "apiKey"
}
}
},
"info": {
"title": "Yoi Server API",
"version": "0.1.0",
"x-yoi-source-digest": "sha256:bac8ba9340cf541183e96715dd4ccd8367f965cdcfc398e6d5091f26a903e1ef"
},
"openapi": "3.1.0",
"paths": {
"/api/repositories": {
"get": {
"operationId": "repository_list_alias",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryListResponse"
}
}
},
"description": "Successful response"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
}
},
"security": [
{
"bearerAuth": []
},
{
"browserSession": []
}
]
}
},
"/api/repositories/{repository_key}": {
"get": {
"operationId": "repository_detail_alias",
"parameters": [
{
"explode": false,
"in": "path",
"name": "repository_key",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryDetailResponse"
}
}
},
"description": "Successful response"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
}
},
"security": [
{
"bearerAuth": []
},
{
"browserSession": []
}
]
}
},
"/api/w/{workspace_id}/repositories": {
"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"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
}
},
"security": [
{
"bearerAuth": []
},
{
"browserSession": []
}
]
},
"post": {
"operationId": "repository_create",
"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"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"409": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"413": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"415": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
}
},
"security": [
{
"bearerAuth": []
},
{
"browserSession": []
}
]
}
},
"/api/w/{workspace_id}/repositories/{repository_key}": {
"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"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
}
},
"security": [
{
"bearerAuth": []
},
{
"browserSession": []
}
]
}
}
}
}