feat: migrate Repository REST to generated Server API
This commit is contained in:
Generated
+2
@@ -4157,8 +4157,10 @@ dependencies = [
|
||||
"api-macros",
|
||||
"protocol",
|
||||
"runtime-api",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tokio",
|
||||
"ts-rs",
|
||||
"webauthn-rs-proto",
|
||||
|
||||
@@ -104,7 +104,13 @@ struct RouteArgs {
|
||||
path: LitStr,
|
||||
operation_id: Option<LitStr>,
|
||||
status: Option<LitInt>,
|
||||
alternate_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 {
|
||||
@@ -114,7 +120,13 @@ impl Parse for RouteArgs {
|
||||
path,
|
||||
operation_id: None,
|
||||
status: None,
|
||||
alternate_status: None,
|
||||
error_status: None,
|
||||
additional_error_statuses: Vec::new(),
|
||||
bearer_auth: None,
|
||||
browser_auth: None,
|
||||
normalize_body_errors: None,
|
||||
openapi: None,
|
||||
};
|
||||
|
||||
while !input.is_empty() {
|
||||
@@ -132,12 +144,58 @@ impl Parse for RouteArgs {
|
||||
"operation_id",
|
||||
)?,
|
||||
"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(
|
||||
&mut result.error_status,
|
||||
input.parse::<LitInt>()?,
|
||||
&key,
|
||||
"error_status",
|
||||
)?,
|
||||
"additional_error_statuses" => {
|
||||
if !result.additional_error_statuses.is_empty() {
|
||||
return Err(syn::Error::new(
|
||||
key.span(),
|
||||
"duplicate `additional_error_statuses` route option",
|
||||
));
|
||||
}
|
||||
let content;
|
||||
syn::bracketed!(content in input);
|
||||
while !content.is_empty() {
|
||||
result.additional_error_statuses.push(content.parse()?);
|
||||
if !content.is_empty() {
|
||||
content.parse::<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(
|
||||
key.span(),
|
||||
@@ -167,6 +225,9 @@ enum Location {
|
||||
Query,
|
||||
Header,
|
||||
Body,
|
||||
/// Trusted request-local context supplied by the server adapter. Extension
|
||||
/// parameters are not part of the wire contract or generated client signature.
|
||||
Extension,
|
||||
}
|
||||
|
||||
impl Location {
|
||||
@@ -176,6 +237,7 @@ impl Location {
|
||||
Self::Query => quote!(Query),
|
||||
Self::Header => quote!(Header),
|
||||
Self::Body => quote!(Body),
|
||||
Self::Extension => unreachable!("extensions are not wire parameters"),
|
||||
};
|
||||
quote!(#api_crate::ParameterLocation::#variant)
|
||||
}
|
||||
@@ -201,7 +263,13 @@ struct Operation {
|
||||
error_body: Option<Type>,
|
||||
fallible: bool,
|
||||
response_status: u16,
|
||||
alternate_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 {
|
||||
@@ -392,6 +460,21 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result<Operation> {
|
||||
"status must be a success status between 200 and 299",
|
||||
));
|
||||
}
|
||||
let alternate_status = parse_status(route.alternate_status.as_ref(), "alternate_status")?;
|
||||
if let Some(status) = alternate_status
|
||||
&& (!(200..=299).contains(&status) || status == response_status)
|
||||
{
|
||||
return Err(syn::Error::new_spanned(
|
||||
route.alternate_status,
|
||||
"alternate_status must be a distinct success status between 200 and 299",
|
||||
));
|
||||
}
|
||||
if alternate_status.is_some() && response_body.is_none() {
|
||||
return Err(syn::Error::new_spanned(
|
||||
route.alternate_status,
|
||||
"alternate_status requires a response body implementing HttpSuccess",
|
||||
));
|
||||
}
|
||||
let error_status = parse_status(route.error_status.as_ref(), "error_status")?;
|
||||
if error_status.is_some() && error_body.is_none() {
|
||||
return Err(syn::Error::new_spanned(
|
||||
@@ -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 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() {
|
||||
return Err(syn::Error::new_spanned(
|
||||
@@ -464,11 +575,20 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result<Operation> {
|
||||
}
|
||||
(Location::Body, rust_name.clone())
|
||||
}
|
||||
Some((Location::Extension, wire_name)) => {
|
||||
if wire_name.is_some() {
|
||||
return Err(syn::Error::new(
|
||||
ident.span(),
|
||||
"#[extension] does not accept a wire name",
|
||||
));
|
||||
}
|
||||
(Location::Extension, rust_name.clone())
|
||||
}
|
||||
None if placeholders.contains(&rust_name) => (Location::Path, rust_name.clone()),
|
||||
None => {
|
||||
return Err(syn::Error::new(
|
||||
ident.span(),
|
||||
"API arguments must use #[path], #[query], #[header], or #[body]; path arguments may omit #[path] when their name matches a placeholder",
|
||||
"API arguments must use #[path], #[query], #[header], #[body], or #[extension]; path arguments may omit #[path] when their name matches a placeholder",
|
||||
));
|
||||
}
|
||||
};
|
||||
@@ -537,7 +657,16 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result<Operation> {
|
||||
error_body,
|
||||
fallible,
|
||||
response_status,
|
||||
alternate_status,
|
||||
error_status,
|
||||
additional_error_statuses,
|
||||
bearer_auth: route.bearer_auth.as_ref().is_some_and(|value| value.value),
|
||||
browser_auth: route.browser_auth.as_ref().is_some_and(|value| value.value),
|
||||
normalize_body_errors: route
|
||||
.normalize_body_errors
|
||||
.as_ref()
|
||||
.is_some_and(|value| value.value),
|
||||
openapi_skip: route.openapi.as_ref().is_some_and(|value| !value.value),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -588,6 +717,7 @@ fn take_location(attrs: &mut Vec<Attribute>) -> syn::Result<Option<(Location, Op
|
||||
Some("query") => Some(Location::Query),
|
||||
Some("header") => Some(Location::Header),
|
||||
Some("body") => Some(Location::Body),
|
||||
Some("extension") => Some(Location::Extension),
|
||||
Some("stream") | Some("multipart") | Some("binary") => {
|
||||
return Err(syn::Error::new_spanned(
|
||||
attr,
|
||||
@@ -881,10 +1011,13 @@ fn reqwest_adapter_tokens(
|
||||
let builder_ident = format_ident!("{}ClientBuilder", ident_text(trait_ident));
|
||||
let methods = api.operations.iter().map(|operation| {
|
||||
let method_ident = &operation.method_ident;
|
||||
let arguments = operation.parameters.iter().map(|parameter| {
|
||||
let arguments = operation.parameters.iter().filter_map(|parameter| {
|
||||
if matches!(parameter.location, Location::Extension) {
|
||||
return None;
|
||||
}
|
||||
let ident = ¶meter.rust_ident;
|
||||
let ty = ¶meter.ty;
|
||||
quote!(#ident: #ty)
|
||||
Some(quote!(#ident: #ty))
|
||||
});
|
||||
let path_segments = operation
|
||||
.path
|
||||
@@ -928,6 +1061,10 @@ fn reqwest_adapter_tokens(
|
||||
.unwrap_or_else(|| quote!(::core::option::Option::None));
|
||||
let method = reqwest_method_tokens(operation.method, api_crate);
|
||||
let response_status = operation.response_status;
|
||||
let accepted_status = operation
|
||||
.alternate_status
|
||||
.map(|alternate| quote!(__actual == #response_status || __actual == #alternate))
|
||||
.unwrap_or_else(|| quote!(__actual == #response_status));
|
||||
let response_type = operation
|
||||
.response_body
|
||||
.as_ref()
|
||||
@@ -985,7 +1122,7 @@ fn reqwest_adapter_tokens(
|
||||
.await
|
||||
.map_err(#api_crate::reqwest::ClientError::from)?;
|
||||
let __actual = __response.status().as_u16();
|
||||
if __actual == #response_status {
|
||||
if #accepted_status {
|
||||
#success
|
||||
}
|
||||
#public_error
|
||||
@@ -1181,17 +1318,69 @@ fn axum_adapter_tokens(
|
||||
};
|
||||
})
|
||||
});
|
||||
let body_extractor = operation.parameters.iter().find_map(|parameter| {
|
||||
if !matches!(parameter.location, Location::Body) {
|
||||
let extension_extractors = operation.parameters.iter().filter_map(|parameter| {
|
||||
if !matches!(parameter.location, Location::Extension) {
|
||||
return None;
|
||||
}
|
||||
let ident = ¶meter.rust_ident;
|
||||
let ty = ¶meter.ty;
|
||||
Some(quote!(#api_crate::axum::framework::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 = ¶meter.rust_ident;
|
||||
let ty = ¶meter.ty;
|
||||
if !operation.normalize_body_errors {
|
||||
return (
|
||||
Some(quote!(#api_crate::axum::framework::Json(#ident): #api_crate::axum::framework::Json<#ty>,)),
|
||||
None,
|
||||
);
|
||||
}
|
||||
let error = operation
|
||||
.error_body
|
||||
.as_ref()
|
||||
.expect("body operations require an error type");
|
||||
(
|
||||
Some(quote!(
|
||||
__body: ::core::result::Result<
|
||||
#api_crate::axum::framework::Json<#ty>,
|
||||
#api_crate::axum::framework::JsonRejection,
|
||||
>,
|
||||
)),
|
||||
Some(quote! {
|
||||
let #ident = match __body {
|
||||
::core::result::Result::Ok(#api_crate::axum::framework::Json(value)) => value,
|
||||
::core::result::Result::Err(rejection) => {
|
||||
let status = rejection.status().as_u16();
|
||||
let error = <#error as #api_crate::HttpRequestError>::from_request_rejection(
|
||||
status,
|
||||
rejection.body_text(),
|
||||
);
|
||||
return #api_crate::axum::json_response(
|
||||
#api_crate::axum::status(status),
|
||||
error,
|
||||
);
|
||||
}
|
||||
};
|
||||
}),
|
||||
)
|
||||
})
|
||||
.unwrap_or((None, None));
|
||||
let call_arguments = operation.parameters.iter().map(|parameter| ¶meter.rust_ident);
|
||||
let response_status = operation.response_status;
|
||||
let success = if operation.response_body.is_some() {
|
||||
let success = if let Some(alternate_status) = operation.alternate_status {
|
||||
quote!({
|
||||
let status = #api_crate::HttpSuccess::status_code(&value);
|
||||
if status == #response_status || status == #alternate_status {
|
||||
#api_crate::axum::json_response(#api_crate::axum::status(status), value)
|
||||
} else {
|
||||
#api_crate::axum::empty_response(#api_crate::axum::status(500))
|
||||
}
|
||||
})
|
||||
} else if operation.response_body.is_some() {
|
||||
quote!(#api_crate::axum::json_response(#api_crate::axum::status(#response_status), value))
|
||||
} else {
|
||||
quote!({ let _ = value; #api_crate::axum::empty_response(#api_crate::axum::status(#response_status)) })
|
||||
@@ -1232,12 +1421,14 @@ fn axum_adapter_tokens(
|
||||
#path_extractor
|
||||
#(#query_extractors)*
|
||||
#header_extractor
|
||||
#(#extension_extractors)*
|
||||
#body_extractor
|
||||
) -> #api_crate::axum::framework::Response
|
||||
where
|
||||
S: super::#trait_ident + Send + Sync + 'static,
|
||||
{
|
||||
#(#header_parsers)*
|
||||
#body_rejection
|
||||
#mapped
|
||||
}
|
||||
}
|
||||
@@ -1285,12 +1476,16 @@ fn openapi_adapter_tokens(
|
||||
to_snake_case(&api.item.ident),
|
||||
span = api.item.ident.span()
|
||||
);
|
||||
let operations = api.operations.iter().map(|operation| {
|
||||
let operations = api
|
||||
.operations
|
||||
.iter()
|
||||
.filter(|operation| !operation.openapi_skip)
|
||||
.map(|operation| {
|
||||
let method = method_name(operation.method);
|
||||
let path = &operation.path;
|
||||
let operation_id = &operation.operation_id;
|
||||
let parameters = operation.parameters.iter().filter_map(|parameter| {
|
||||
if matches!(parameter.location, Location::Body) {
|
||||
if matches!(parameter.location, Location::Body | Location::Extension) {
|
||||
return None;
|
||||
}
|
||||
let ty = ¶meter.ty;
|
||||
@@ -1299,7 +1494,7 @@ fn openapi_adapter_tokens(
|
||||
Location::Path => "path",
|
||||
Location::Query => "query",
|
||||
Location::Header => "header",
|
||||
Location::Body => unreachable!(),
|
||||
Location::Body | Location::Extension => unreachable!(),
|
||||
};
|
||||
Some(quote! {
|
||||
operation.parameter::<#ty>(#name, #location)?;
|
||||
@@ -1319,6 +1514,16 @@ fn openapi_adapter_tokens(
|
||||
operation.empty_response(#status, "Successful response")?;
|
||||
},
|
||||
};
|
||||
let alternate_response = operation.alternate_status.map(|status| {
|
||||
match operation.response_body.as_ref() {
|
||||
Some(ty) => quote! {
|
||||
operation.response::<#ty>(#status, "application/json", "Alternate successful response")?;
|
||||
},
|
||||
None => quote! {
|
||||
operation.empty_response(#status, "Alternate successful response")?;
|
||||
},
|
||||
}
|
||||
});
|
||||
let error = operation
|
||||
.error_status
|
||||
.zip(operation.error_body.as_ref())
|
||||
@@ -1327,14 +1532,39 @@ fn openapi_adapter_tokens(
|
||||
operation.response::<#ty>(#status, "application/json", "Error response")?;
|
||||
}
|
||||
});
|
||||
let additional_errors = operation
|
||||
.error_body
|
||||
.as_ref()
|
||||
.map(|ty| {
|
||||
operation
|
||||
.additional_error_statuses
|
||||
.iter()
|
||||
.map(|status| {
|
||||
quote! {
|
||||
operation.response::<#ty>(#status, "application/json", "Error response")?;
|
||||
}
|
||||
})
|
||||
.collect::<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! {
|
||||
{
|
||||
let mut operation = builder.operation(#method, #path, #operation_id)?;
|
||||
#(#parameters)*
|
||||
#bearer_auth
|
||||
#browser_auth
|
||||
#request
|
||||
#response
|
||||
#alternate_response
|
||||
#error
|
||||
#(#additional_errors)*
|
||||
operation.finish()?;
|
||||
}
|
||||
}
|
||||
@@ -1383,7 +1613,13 @@ fn expand_api(api: ApiDefinition) -> syn::Result<proc_macro2::TokenStream> {
|
||||
.map(|operation| {
|
||||
let marker_ident = &operation.marker_ident;
|
||||
let metadata = metadata_tokens(operation, &api_crate);
|
||||
let parameter_types = operation.parameters.iter().map(|parameter| ¶meter.ty);
|
||||
let parameter_types = operation.parameters.iter().filter_map(|parameter| {
|
||||
if matches!(parameter.location, Location::Extension) {
|
||||
None
|
||||
} else {
|
||||
Some(¶meter.ty)
|
||||
}
|
||||
});
|
||||
let request_type = operation
|
||||
.request_body
|
||||
.as_ref()
|
||||
@@ -1472,17 +1708,20 @@ fn metadata_tokens(
|
||||
},
|
||||
None => quote!(::core::option::Option::None),
|
||||
};
|
||||
let parameters = operation.parameters.iter().map(|parameter| {
|
||||
let parameters = operation.parameters.iter().filter_map(|parameter| {
|
||||
if matches!(parameter.location, Location::Extension) {
|
||||
return None;
|
||||
}
|
||||
let rust_name = ¶meter.rust_name;
|
||||
let wire_name = ¶meter.wire_name;
|
||||
let location = parameter.location.tokens(api_crate);
|
||||
quote! {
|
||||
Some(quote! {
|
||||
#api_crate::ParameterMetadata {
|
||||
rust_name: #rust_name,
|
||||
wire_name: #wire_name,
|
||||
location: #location,
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
quote! {
|
||||
|
||||
@@ -52,7 +52,7 @@ pub fn status(code: u16) -> StatusCode {
|
||||
pub mod framework {
|
||||
pub use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, Query, State},
|
||||
extract::{Extension, Path, Query, State, rejection::JsonRejection},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::Response,
|
||||
routing::{delete, get, head, options, patch, post, put},
|
||||
|
||||
@@ -38,13 +38,21 @@
|
||||
//! `options`. The first argument is a path template. `operation_id` defaults to the Rust
|
||||
//! method name but should be set explicitly for contracts which must remain stable while Rust
|
||||
//! names evolve. Success status defaults to `200`, except an empty (`()`) response defaults to
|
||||
//! `204`. A public error inferred from `Result<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
|
||||
//! unannotated argument whose Rust name occurs in the route template is inferred as a path
|
||||
//! Arguments are classified with `#[body]`, `#[query]`, `#[header]`, `#[path]`, or `#[extension]`.
|
||||
//! Extension values are trusted server-local Axum context: generated clients and OpenAPI omit them.
|
||||
//! An unannotated argument whose Rust name occurs in the route template is inferred as a path
|
||||
//! argument. Header attributes may carry a wire name, as in `#[header("x-request-id")]`.
|
||||
//! Exactly one JSON body is allowed. JSON request, response, and error bodies must be named
|
||||
//! Rust types; tuples, references, arrays, and other anonymous structural types are rejected.
|
||||
//! `openapi = false` explicitly excludes an operation whose wire body cannot yet satisfy the
|
||||
//! strict OpenAPI schema boundary; Reqwest and Axum adapters are still generated.
|
||||
//!
|
||||
//! # Generated names
|
||||
//!
|
||||
@@ -200,6 +208,18 @@ pub trait HttpError {
|
||||
fn status_code(&self) -> u16;
|
||||
}
|
||||
|
||||
/// Successful JSON response that selects one of an operation's declared success statuses.
|
||||
///
|
||||
/// Operations opt in with `alternate_status`; ordinary one-status operations remain fully static.
|
||||
pub trait HttpSuccess {
|
||||
fn status_code(&self) -> u16;
|
||||
}
|
||||
|
||||
/// Public error type capable of normalizing Axum request-body rejections.
|
||||
pub trait HttpRequestError: HttpError {
|
||||
fn from_request_rejection(status: u16, message: String) -> Self;
|
||||
}
|
||||
|
||||
/// Marker used when an operation has no request, response, or public error body.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum NoBody {}
|
||||
|
||||
@@ -131,6 +131,7 @@ pub struct OpenApiBuilder {
|
||||
schemas: BTreeMap<String, Value>,
|
||||
operation_ids: BTreeSet<String>,
|
||||
uses_bearer_auth: bool,
|
||||
uses_browser_auth: bool,
|
||||
}
|
||||
|
||||
impl OpenApiBuilder {
|
||||
@@ -157,6 +158,7 @@ impl OpenApiBuilder {
|
||||
schemas: BTreeMap::new(),
|
||||
operation_ids: BTreeSet::new(),
|
||||
uses_bearer_auth: false,
|
||||
uses_browser_auth: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -199,6 +201,7 @@ impl OpenApiBuilder {
|
||||
request_body: None,
|
||||
responses: BTreeMap::new(),
|
||||
bearer_security_required: None,
|
||||
browser_security_required: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -214,17 +217,32 @@ impl OpenApiBuilder {
|
||||
let schemas = self.schemas.into_iter().collect::<Map<_, _>>();
|
||||
let mut components = Map::new();
|
||||
components.insert("schemas".to_owned(), Value::Object(schemas));
|
||||
let mut security_schemes = Map::new();
|
||||
if self.uses_bearer_auth {
|
||||
components.insert(
|
||||
"securitySchemes".to_owned(),
|
||||
security_schemes.insert(
|
||||
"bearerAuth".to_owned(),
|
||||
json!({
|
||||
"bearerAuth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer"
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
if self.uses_browser_auth {
|
||||
security_schemes.insert(
|
||||
"browserSession".to_owned(),
|
||||
json!({
|
||||
"type": "apiKey",
|
||||
"in": "cookie",
|
||||
"name": "yoi_workspace_session"
|
||||
}),
|
||||
);
|
||||
}
|
||||
if !security_schemes.is_empty() {
|
||||
components.insert(
|
||||
"securitySchemes".to_owned(),
|
||||
Value::Object(security_schemes),
|
||||
);
|
||||
}
|
||||
|
||||
let document = canonicalize(json!({
|
||||
"openapi": "3.1.0",
|
||||
@@ -295,6 +313,7 @@ pub struct OpenApiOperation<'a> {
|
||||
request_body: Option<Value>,
|
||||
responses: BTreeMap<String, Value>,
|
||||
bearer_security_required: Option<bool>,
|
||||
browser_security_required: bool,
|
||||
}
|
||||
|
||||
impl OpenApiOperation<'_> {
|
||||
@@ -338,6 +357,16 @@ impl OpenApiOperation<'_> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn bearer_authentication(&mut self) {
|
||||
self.parent.uses_bearer_auth = true;
|
||||
self.bearer_security_required = Some(true);
|
||||
}
|
||||
|
||||
pub fn browser_authentication(&mut self) {
|
||||
self.parent.uses_browser_auth = true;
|
||||
self.browser_security_required = true;
|
||||
}
|
||||
|
||||
pub fn request_body<T: OpenApiSchema>(
|
||||
&mut self,
|
||||
content_type: &'static str,
|
||||
@@ -418,15 +447,18 @@ impl OpenApiOperation<'_> {
|
||||
if let Some(request_body) = self.request_body {
|
||||
operation.insert("requestBody".to_owned(), request_body);
|
||||
}
|
||||
if let Some(required) = self.bearer_security_required {
|
||||
operation.insert(
|
||||
"security".to_owned(),
|
||||
if required {
|
||||
json!([{ "bearerAuth": [] }])
|
||||
} else {
|
||||
json!([{}, { "bearerAuth": [] }])
|
||||
},
|
||||
);
|
||||
let mut security = Vec::new();
|
||||
if self.bearer_security_required == Some(false) {
|
||||
security.push(json!({}));
|
||||
}
|
||||
if self.bearer_security_required.is_some() {
|
||||
security.push(json!({ "bearerAuth": [] }));
|
||||
}
|
||||
if self.browser_security_required {
|
||||
security.push(json!({ "browserSession": [] }));
|
||||
}
|
||||
if !security.is_empty() {
|
||||
operation.insert("security".to_owned(), Value::Array(security));
|
||||
}
|
||||
self.parent
|
||||
.paths
|
||||
|
||||
@@ -192,6 +192,10 @@ impl BackendApiClient {
|
||||
format!("Bearer {}", self.access_token.0)
|
||||
}
|
||||
|
||||
pub(crate) fn asynchronous_client(&self) -> reqwest::Client {
|
||||
self.asynchronous.clone()
|
||||
}
|
||||
|
||||
pub async fn require_success(
|
||||
&self,
|
||||
response: reqwest::Response,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
use crate::{BackendApiClient, BackendApiClientError};
|
||||
use reqwest::Method;
|
||||
use server_api::{
|
||||
InitialRepositoryIntent, RepositoryListResponse, RepositorySummary,
|
||||
WorkspaceCatalogListResponse, WorkspaceCreateRequest, WorkspaceCreateResponse,
|
||||
WorkspaceSummary,
|
||||
InitialRepositoryIntent, RepositorySummary, WorkspaceCatalogListResponse,
|
||||
WorkspaceCreateRequest, WorkspaceCreateResponse, WorkspaceSummary,
|
||||
};
|
||||
use std::fmt;
|
||||
|
||||
@@ -32,6 +31,7 @@ pub enum BackendWorkspaceClientError {
|
||||
InvalidTarget(String),
|
||||
Api(BackendApiClientError),
|
||||
Http(reqwest::Error),
|
||||
RepositoryApi(server_api::client_support::ClientError<server_api::RepositoryApiError>),
|
||||
}
|
||||
|
||||
impl fmt::Display for BackendWorkspaceClientError {
|
||||
@@ -40,6 +40,7 @@ impl fmt::Display for BackendWorkspaceClientError {
|
||||
Self::InvalidTarget(message) => f.write_str(message),
|
||||
Self::Api(error) => write!(f, "{error}"),
|
||||
Self::Http(error) => write!(f, "{error}"),
|
||||
Self::RepositoryApi(error) => write!(f, "{error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,6 +59,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(
|
||||
target: &BackendWorkspaceCatalogTarget,
|
||||
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
|
||||
@@ -75,6 +132,21 @@ pub fn list_backend_workspaces_blocking(
|
||||
pub fn list_backend_workspace_repositories_blocking(
|
||||
target: &BackendWorkspaceCatalogTarget,
|
||||
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> {
|
||||
if workspace_id.is_empty()
|
||||
|| workspace_id.len() > 200
|
||||
@@ -86,12 +158,19 @@ pub fn list_backend_workspace_repositories_blocking(
|
||||
"Workspace id returned by Backend is invalid".to_string(),
|
||||
));
|
||||
}
|
||||
let client = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||
let 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
|
||||
.blocking_request(Method::GET, &format!("/api/w/{workspace_id}/repositories"))?
|
||||
.send()?;
|
||||
client.check_status(response.status())?;
|
||||
let response = response.json::<RepositoryListResponse>()?;
|
||||
.repository_list(workspace_id.to_owned())
|
||||
.await
|
||||
.map_err(|error| repository_client_error(backend, error))?;
|
||||
if response.workspace_id != workspace_id {
|
||||
return Err(BackendWorkspaceClientError::InvalidTarget(
|
||||
"Repository catalog response does not match the requested Workspace".to_string(),
|
||||
@@ -170,6 +249,124 @@ mod tests {
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repository_catalog_uses_generated_path_and_bearer_authorizer() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let handle = thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut request = vec![0; 4096];
|
||||
let read = stream.read(&mut request).unwrap();
|
||||
let request = String::from_utf8_lossy(&request[..read]).to_ascii_lowercase();
|
||||
assert!(request.starts_with("get /api/w/workspace-test/repositories "));
|
||||
assert!(request.contains("authorization: bearer repository-secret\r\n"));
|
||||
let body = r#"{"workspace_id":"workspace-test","items":[],"source":"workspace","diagnostics":[]}"#;
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
let client =
|
||||
BackendApiClient::from_access_token_for_test(&base_url, "repository-secret").unwrap();
|
||||
|
||||
assert!(
|
||||
list_backend_workspace_repositories_with_client(&client, "workspace-test")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repository_catalog_enforces_generated_response_limit() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let handle = thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut request = vec![0; 4096];
|
||||
let _ = stream.read(&mut request).unwrap();
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
REPOSITORY_RESPONSE_LIMIT + 1,
|
||||
)
|
||||
.unwrap();
|
||||
stream.flush().unwrap();
|
||||
let chunk = [b' '; 8192];
|
||||
let mut remaining = REPOSITORY_RESPONSE_LIMIT + 1;
|
||||
while remaining > 0 {
|
||||
let length = remaining.min(chunk.len());
|
||||
if stream.write_all(&chunk[..length]).is_err() {
|
||||
break;
|
||||
}
|
||||
remaining -= length;
|
||||
}
|
||||
});
|
||||
let client =
|
||||
BackendApiClient::from_access_token_for_test(&base_url, "repository-secret").unwrap();
|
||||
|
||||
let error = list_backend_workspace_repositories_with_client(&client, "workspace-test")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(
|
||||
&error,
|
||||
BackendWorkspaceClientError::RepositoryApi(
|
||||
server_api::client_support::ClientError::Failure(
|
||||
server_api::client_support::ClientFailure::ResponseTooLarge {
|
||||
limit: REPOSITORY_RESPONSE_LIMIT
|
||||
}
|
||||
)
|
||||
)
|
||||
),
|
||||
"unexpected error: {error:?}"
|
||||
);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_client_preserves_authentication_error_taxonomy() {
|
||||
let client =
|
||||
BackendApiClient::from_access_token_for_test("https://backend.example.test", "token")
|
||||
.unwrap();
|
||||
let unauthorized = repository_client_error(
|
||||
&client,
|
||||
server_api::client_support::ClientError::public(
|
||||
reqwest::StatusCode::UNAUTHORIZED,
|
||||
server_api::RepositoryApiError::new(
|
||||
401,
|
||||
"Unauthorized",
|
||||
"authentication required",
|
||||
Vec::new(),
|
||||
),
|
||||
),
|
||||
);
|
||||
assert!(matches!(
|
||||
unauthorized,
|
||||
BackendWorkspaceClientError::Api(BackendApiClientError::Unauthorized { .. })
|
||||
));
|
||||
let forbidden = repository_client_error(
|
||||
&client,
|
||||
server_api::client_support::ClientError::public(
|
||||
reqwest::StatusCode::FORBIDDEN,
|
||||
server_api::RepositoryApiError::new(
|
||||
403,
|
||||
"Forbidden",
|
||||
"repository access denied",
|
||||
Vec::new(),
|
||||
),
|
||||
),
|
||||
);
|
||||
assert!(matches!(
|
||||
forbidden,
|
||||
BackendWorkspaceClientError::Api(BackendApiClientError::Forbidden { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_request_keeps_operation_key_for_exact_retry() {
|
||||
let request = CreateBackendWorkspaceRequest {
|
||||
|
||||
@@ -10,10 +10,12 @@ default = []
|
||||
typescript = ["dep:ts-rs", "protocol/typescript"]
|
||||
|
||||
[dependencies]
|
||||
api-macros = { workspace = true, features = ["reqwest", "axum"] }
|
||||
api-macros = { workspace = true, features = ["reqwest", "axum", "openapi"] }
|
||||
protocol.workspace = true
|
||||
runtime-api.workspace = true
|
||||
schemars.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
sha2.workspace = true
|
||||
ts-rs = { version = "12.0.1", optional = true }
|
||||
webauthn-rs-proto = "0.5.5"
|
||||
workdir.workspace = true
|
||||
|
||||
@@ -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
@@ -8,7 +8,9 @@ use api_macros::api;
|
||||
pub use api_macros::axum as server_support;
|
||||
pub use api_macros::reqwest as client_support;
|
||||
pub use api_macros::{ApiContract, HttpMethod};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use webauthn_rs_proto::{
|
||||
CreationChallengeResponse, PublicKeyCredential, RegisterPublicKeyCredential,
|
||||
RequestChallengeResponse,
|
||||
@@ -17,6 +19,69 @@ use webauthn_rs_proto::{
|
||||
pub type ServerApiError = runtime_api::RuntimeApiError;
|
||||
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)]
|
||||
pub struct WorkspaceWorkerSessionResponse {
|
||||
pub subject: WorkspaceWorkerSubject,
|
||||
@@ -24,12 +89,13 @@ pub struct WorkspaceWorkerSessionResponse {
|
||||
pub observation: runtime_api::WorkerSessionAvailability,
|
||||
}
|
||||
|
||||
#[api(reqwest, axum)]
|
||||
#[api(reqwest, axum, openapi)]
|
||||
pub trait ServerApi {
|
||||
#[get(
|
||||
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/session",
|
||||
status = 200,
|
||||
error_status = 400
|
||||
error_status = 400,
|
||||
openapi = false
|
||||
)]
|
||||
async fn worker_session(
|
||||
&self,
|
||||
@@ -37,6 +103,104 @@ pub trait ServerApi {
|
||||
#[path] runtime_id: String,
|
||||
#[path] worker_id: String,
|
||||
) -> 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.
|
||||
@@ -394,7 +558,7 @@ pub fn validate_repository_key(value: &str) -> Result<(), RepositoryKeyError> {
|
||||
///
|
||||
/// Local paths remain distinct from network Git transports so callers cannot
|
||||
/// accidentally treat an unmaterialized remote as a server-local filesystem path.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RepositorySourceKind {
|
||||
@@ -451,7 +615,7 @@ impl<'de> Deserialize<'de> for RepositorySourceKind {
|
||||
}
|
||||
|
||||
/// Stable Repository source identity stored by Workspace authority.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct RepositorySource {
|
||||
pub kind: RepositorySourceKind,
|
||||
@@ -464,7 +628,7 @@ pub struct RepositorySource {
|
||||
///
|
||||
/// The Server parses and canonicalizes `source`; callers cannot assert a
|
||||
/// transport classification or supply credential material through this DTO.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CreateWorkspaceRepositoryRequest {
|
||||
pub repository_key: String,
|
||||
@@ -473,14 +637,20 @@ pub struct CreateWorkspaceRepositoryRequest {
|
||||
pub default_ref: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
pub struct CreateWorkspaceRepositoryResponse {
|
||||
pub workspace_id: String,
|
||||
pub repository_key: String,
|
||||
pub replayed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
impl api_macros::HttpSuccess for CreateWorkspaceRepositoryResponse {
|
||||
fn status_code(&self) -> u16 {
|
||||
if self.replayed { 200 } else { 201 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RepositoryObservedStatus {
|
||||
@@ -1125,7 +1295,7 @@ pub enum WorkspaceProfileSourceProvenance {
|
||||
ProjectProfileSourceTree,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RepositoryDiagnostic {
|
||||
@@ -1134,7 +1304,7 @@ pub struct RepositoryDiagnostic {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct GitRemoteSummary {
|
||||
@@ -1142,7 +1312,7 @@ pub struct GitRemoteSummary {
|
||||
pub fetch_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct GitRepositorySummary {
|
||||
@@ -1153,7 +1323,7 @@ pub struct GitRepositorySummary {
|
||||
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))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RepositorySummary {
|
||||
@@ -1162,6 +1332,7 @@ pub struct RepositorySummary {
|
||||
pub provider: String,
|
||||
pub source: RepositorySource,
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||
pub source_revision: u64,
|
||||
pub source_fingerprint: String,
|
||||
pub observed_status: RepositoryObservedStatus,
|
||||
@@ -1194,7 +1365,7 @@ pub struct GitCommitSummary {
|
||||
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))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RepositoryListResponse {
|
||||
@@ -1204,7 +1375,7 @@ pub struct RepositoryListResponse {
|
||||
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))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RepositoryDetailResponse {
|
||||
@@ -1282,7 +1453,7 @@ pub struct RepositoryLogResponse {
|
||||
pub const TICKET_RELATIONS_QUERY_PATH: &str = "/tickets/relations/search";
|
||||
pub const TICKET_ORCHESTRATION_PLANS_QUERY_PATH: &str = "/tickets/orchestration-plans/search";
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DiagnosticSeverity {
|
||||
@@ -1291,7 +1462,7 @@ pub enum DiagnosticSeverity {
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Diagnostic {
|
||||
@@ -3645,10 +3816,14 @@ mod tests {
|
||||
#[test]
|
||||
fn worker_session_contract_and_flattened_availability_are_stable() {
|
||||
let operations = ServerApiMetadata::OPERATIONS;
|
||||
assert_eq!(operations.len(), 1);
|
||||
assert_eq!(operations[0].method, HttpMethod::Get);
|
||||
assert_eq!(operations.len(), 6);
|
||||
let operation = operations
|
||||
.iter()
|
||||
.find(|operation| operation.operation_id == "worker_session")
|
||||
.expect("worker-session operation must remain in ServerApi metadata");
|
||||
assert_eq!(operation.method, HttpMethod::Get);
|
||||
assert_eq!(
|
||||
operations[0].path,
|
||||
operation.path,
|
||||
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/session"
|
||||
);
|
||||
|
||||
@@ -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"))]
|
||||
mod typescript_tests {
|
||||
#[test]
|
||||
|
||||
@@ -1629,11 +1629,31 @@ fn signed_request_target(uri: &Uri) -> &str {
|
||||
.unwrap_or_else(|| uri.path())
|
||||
}
|
||||
|
||||
fn repository_api_rejection(path: &str, status: StatusCode, message: &str) -> Response {
|
||||
if path == "/api/repositories"
|
||||
|| path.starts_with("/api/repositories/")
|
||||
|| (path.starts_with("/api/w/") && path.contains("/repositories"))
|
||||
{
|
||||
return (
|
||||
status,
|
||||
Json(server_api::RepositoryApiError::new(
|
||||
status.as_u16(),
|
||||
status.canonical_reason().unwrap_or("error"),
|
||||
message,
|
||||
Vec::new(),
|
||||
)),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
status.into_response()
|
||||
}
|
||||
|
||||
async fn authorize_scoped_workspace_request(
|
||||
api: &WorkspaceServerApi,
|
||||
workspace_id: &str,
|
||||
request: &mut Request,
|
||||
) -> std::result::Result<(), Response> {
|
||||
let request_path = request.uri().path().to_owned();
|
||||
let proof = request
|
||||
.headers()
|
||||
.get(worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER)
|
||||
@@ -1645,7 +1665,13 @@ async fn authorize_scoped_workspace_request(
|
||||
let body = std::mem::take(request.body_mut());
|
||||
let body = axum::body::to_bytes(body, 16 * 1024 * 1024)
|
||||
.await
|
||||
.map_err(|_| StatusCode::BAD_REQUEST.into_response())?;
|
||||
.map_err(|_| {
|
||||
repository_api_rejection(
|
||||
&request_path,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid request body",
|
||||
)
|
||||
})?;
|
||||
let digest = worker_runtime::auth::request_body_digest(&body);
|
||||
*request.body_mut() = axum::body::Body::from(body);
|
||||
let permission = if path
|
||||
@@ -1675,7 +1701,13 @@ async fn authorize_scoped_workspace_request(
|
||||
&digest,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| StatusCode::UNAUTHORIZED.into_response())?;
|
||||
.map_err(|_| {
|
||||
repository_api_rejection(
|
||||
&request_path,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"invalid runtime request proof",
|
||||
)
|
||||
})?;
|
||||
request.extensions_mut().insert(source);
|
||||
if !matches!(
|
||||
*request.method(),
|
||||
@@ -1687,11 +1719,11 @@ async fn authorize_scoped_workspace_request(
|
||||
.map_err(server_error_response)?
|
||||
.is_some_and(|workspace| workspace.state == "active")
|
||||
{
|
||||
return Err((
|
||||
return Err(repository_api_rejection(
|
||||
&request_path,
|
||||
StatusCode::CONFLICT,
|
||||
"Workspace is deleting and no longer accepts mutations",
|
||||
)
|
||||
.into_response());
|
||||
));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
@@ -1699,7 +1731,13 @@ async fn authorize_scoped_workspace_request(
|
||||
let actor = resolve_server_actor(api, request.headers())
|
||||
.await
|
||||
.map_err(server_error_response)?
|
||||
.ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?;
|
||||
.ok_or_else(|| {
|
||||
repository_api_rejection(
|
||||
&request_path,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"authentication required",
|
||||
)
|
||||
})?;
|
||||
|
||||
let cookie_authenticated = matches!(actor.auth_method, ActorAuthMethod::BrowserSession);
|
||||
let mutating = !matches!(
|
||||
@@ -1715,7 +1753,11 @@ async fn authorize_scoped_workspace_request(
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
{
|
||||
return Err(StatusCode::FORBIDDEN.into_response());
|
||||
return Err(repository_api_rejection(
|
||||
&request_path,
|
||||
StatusCode::FORBIDDEN,
|
||||
"cross-origin browser mutation denied",
|
||||
));
|
||||
}
|
||||
}
|
||||
request.extensions_mut().insert(actor);
|
||||
@@ -1727,11 +1769,11 @@ async fn authorize_scoped_workspace_request(
|
||||
.map_err(server_error_response)?
|
||||
.is_some_and(|workspace| workspace.state == "active")
|
||||
{
|
||||
return Err((
|
||||
return Err(repository_api_rejection(
|
||||
&request_path,
|
||||
StatusCode::CONFLICT,
|
||||
"Workspace is deleting and no longer accepts mutations",
|
||||
)
|
||||
.into_response());
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1744,6 +1786,7 @@ async fn authorize_workspace_api_request(
|
||||
if !request.uri().path().starts_with("/api/") {
|
||||
return next.run(request).await;
|
||||
}
|
||||
let request_path = request.uri().path().to_owned();
|
||||
let public_server_api = is_server_global_forward(request.uri().path());
|
||||
let workspace_id = api.workspace_id().to_owned();
|
||||
let proof = request
|
||||
@@ -1756,7 +1799,11 @@ async fn authorize_workspace_api_request(
|
||||
let path = signed_request_target(request.uri()).to_owned();
|
||||
let body = std::mem::take(request.body_mut());
|
||||
let Ok(body) = axum::body::to_bytes(body, 16 * 1024 * 1024).await else {
|
||||
return StatusCode::BAD_REQUEST.into_response();
|
||||
return repository_api_rejection(
|
||||
&request_path,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid request body",
|
||||
);
|
||||
};
|
||||
let digest = worker_runtime::auth::request_body_digest(&body);
|
||||
*request.body_mut() = axum::body::Body::from(body);
|
||||
@@ -1787,7 +1834,11 @@ async fn authorize_workspace_api_request(
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return StatusCode::UNAUTHORIZED.into_response();
|
||||
return repository_api_rejection(
|
||||
&request_path,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"invalid runtime request proof",
|
||||
);
|
||||
};
|
||||
request.extensions_mut().insert(source);
|
||||
if !matches!(
|
||||
@@ -1801,11 +1852,11 @@ async fn authorize_workspace_api_request(
|
||||
.flatten()
|
||||
.is_some_and(|workspace| workspace.state == "active")
|
||||
{
|
||||
return (
|
||||
return repository_api_rejection(
|
||||
&request_path,
|
||||
StatusCode::CONFLICT,
|
||||
"Workspace is deleting and no longer accepts mutations",
|
||||
)
|
||||
.into_response();
|
||||
);
|
||||
}
|
||||
return next.run(request).await;
|
||||
}
|
||||
@@ -1820,7 +1871,13 @@ async fn authorize_workspace_api_request(
|
||||
{
|
||||
Ok(Some(actor)) => actor,
|
||||
Ok(None) if public_server_api => return next.run(request).await,
|
||||
Ok(None) | Err(_) => return StatusCode::UNAUTHORIZED.into_response(),
|
||||
Ok(None) | Err(_) => {
|
||||
return repository_api_rejection(
|
||||
&request_path,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"authentication required",
|
||||
);
|
||||
}
|
||||
};
|
||||
let cookie_authenticated = matches!(actor.auth_method, ActorAuthMethod::BrowserSession);
|
||||
let mutating = !matches!(
|
||||
@@ -1836,7 +1893,11 @@ async fn authorize_workspace_api_request(
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
{
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
return repository_api_rejection(
|
||||
&request_path,
|
||||
StatusCode::FORBIDDEN,
|
||||
"cross-origin browser mutation denied",
|
||||
);
|
||||
}
|
||||
}
|
||||
request.extensions_mut().insert(actor);
|
||||
@@ -1849,11 +1910,11 @@ async fn authorize_workspace_api_request(
|
||||
.flatten()
|
||||
.is_some_and(|workspace| workspace.state == "active")
|
||||
{
|
||||
return (
|
||||
return repository_api_rejection(
|
||||
&request_path,
|
||||
StatusCode::CONFLICT,
|
||||
"Workspace is deleting and no longer accepts mutations",
|
||||
)
|
||||
.into_response();
|
||||
);
|
||||
}
|
||||
next.run(request).await
|
||||
}
|
||||
@@ -1964,7 +2025,7 @@ async fn dispatch_workspace_request(
|
||||
}
|
||||
};
|
||||
let Some(router) = router else {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
return repository_api_rejection(&path, StatusCode::NOT_FOUND, "workspace was not found");
|
||||
};
|
||||
match router.oneshot(request).await {
|
||||
Ok(response) => response,
|
||||
@@ -3133,6 +3194,77 @@ impl server_api::ServerApi for WorkspaceApi {
|
||||
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 {
|
||||
@@ -3457,16 +3589,6 @@ fn build_inner_router(api: WorkspaceApi) -> Router {
|
||||
"/api/w/{workspace_id}/objectives/{objective_id}/ticket-links/{ticket_id}",
|
||||
delete(scoped_unlink_objective_ticket),
|
||||
)
|
||||
.route("/api/repositories", get(list_repositories))
|
||||
.route(
|
||||
"/api/w/{workspace_id}/repositories",
|
||||
get(scoped_list_repositories).post(scoped_create_repository),
|
||||
)
|
||||
.route("/api/repositories/{repository_key}", get(repository_detail))
|
||||
.route(
|
||||
"/api/w/{workspace_id}/repositories/{repository_key}",
|
||||
get(scoped_repository_detail),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/repositories/{repository_key}/ssh-connection-test",
|
||||
post(scoped_probe_repository_ssh_connection)
|
||||
@@ -18894,11 +19016,21 @@ impl ApiError {
|
||||
fn with_diagnostics(error: Error, diagnostics: Vec<RuntimeDiagnostic>) -> Self {
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let status = match &self.error {
|
||||
fn api_error_status(error: &Error) -> StatusCode {
|
||||
match error {
|
||||
Error::BrowserReopenConfirmationRequired | Error::WorkspacePermissionDenied(_) => {
|
||||
StatusCode::FORBIDDEN
|
||||
}
|
||||
@@ -18914,9 +19046,7 @@ impl IntoResponse for ApiError {
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
Error::Ticket(ticket::TicketError::NotFound(_))
|
||||
| Error::MergeRequest(merge_request::MergeRequestError::NotFound) => {
|
||||
StatusCode::NOT_FOUND
|
||||
}
|
||||
| Error::MergeRequest(merge_request::MergeRequestError::NotFound) => StatusCode::NOT_FOUND,
|
||||
Error::MergeRequest(merge_request::MergeRequestError::Validation(_)) => {
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
@@ -18945,9 +19075,7 @@ impl IntoResponse for ApiError {
|
||||
Error::RuntimeOperationFailed { code, .. } if code == "repository_not_configured" => {
|
||||
StatusCode::NOT_FOUND
|
||||
}
|
||||
Error::RuntimeOperationFailed { code, .. }
|
||||
if code == "repository_provider_unsupported" =>
|
||||
{
|
||||
Error::RuntimeOperationFailed { code, .. } if code == "repository_provider_unsupported" => {
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_auth_failed" => {
|
||||
@@ -19008,13 +19136,22 @@ impl IntoResponse for ApiError {
|
||||
Error::WorkspaceSigningIdentity { .. } => StatusCode::SERVICE_UNAVAILABLE,
|
||||
Error::RuntimeOperationFailed { .. } => StatusCode::BAD_GATEWAY,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
let response_message = match &self.error {
|
||||
}
|
||||
}
|
||||
|
||||
fn api_error_response_message(error: &Error) -> String {
|
||||
match error {
|
||||
Error::RuntimeOperationFailed { code, message, .. } => {
|
||||
format!("{code}: {}", sanitize_backend_error(message))
|
||||
}
|
||||
_ => sanitize_backend_error(&self.error.to_string()),
|
||||
};
|
||||
_ => sanitize_backend_error(&error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let status = api_error_status(&self.error);
|
||||
let response_message = api_error_response_message(&self.error);
|
||||
let log = ApiErrorLog {
|
||||
kind: self
|
||||
.diagnostics
|
||||
@@ -19077,6 +19214,46 @@ mod tests {
|
||||
SqliteWorkspaceStore, UserRecord, WorkspaceRecord, WorkspaceRuntimeBinding,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TestBearerAuthorizer(&'static str);
|
||||
|
||||
impl server_api::client_support::RequestAuthorizer for TestBearerAuthorizer {
|
||||
fn authorize(
|
||||
&self,
|
||||
_request: server_api::client_support::AuthorizerRequest<'_>,
|
||||
) -> std::result::Result<
|
||||
axum::http::HeaderMap,
|
||||
server_api::client_support::AuthorizationError,
|
||||
> {
|
||||
let mut headers = axum::http::HeaderMap::new();
|
||||
headers.insert(
|
||||
axum::http::header::AUTHORIZATION,
|
||||
format!("Bearer {}", self.0)
|
||||
.parse()
|
||||
.map_err(|_| server_api::client_support::AuthorizationError::new())?,
|
||||
);
|
||||
Ok(headers)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repository_conflict_rejections_use_the_typed_error_contract() {
|
||||
let response = repository_api_rejection(
|
||||
"/api/w/workspace-test/repositories",
|
||||
StatusCode::CONFLICT,
|
||||
"Workspace is deleting and no longer accepts mutations",
|
||||
);
|
||||
assert_eq!(response.status(), StatusCode::CONFLICT);
|
||||
let body: server_api::RepositoryApiError =
|
||||
serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(body.error, "Conflict");
|
||||
assert_eq!(
|
||||
body.message,
|
||||
"Workspace is deleting and no longer accepts mutations"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_resource_timeout_maps_to_gateway_timeout() {
|
||||
assert_eq!(
|
||||
@@ -22871,6 +23048,122 @@ mod tests {
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap()
|
||||
};
|
||||
let anonymous_repository = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(&repositories_uri)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(anonymous_repository.status(), StatusCode::UNAUTHORIZED);
|
||||
let anonymous_body: Value = serde_json::from_slice(
|
||||
&to_bytes(anonymous_repository.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(anonymous_body["error"], "Unauthorized");
|
||||
|
||||
let unknown_field = app
|
||||
.clone()
|
||||
.oneshot(create_repository(serde_json::json!({
|
||||
"repository_key": "unknown-field",
|
||||
"source": temp.path().join("unknown-field").display().to_string(),
|
||||
"unexpected": true
|
||||
})))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(unknown_field.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let unknown_field_body: Value = serde_json::from_slice(
|
||||
&to_bytes(unknown_field.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(unknown_field_body["error"], "Unprocessable Entity");
|
||||
|
||||
let malformed = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(&repositories_uri)
|
||||
.header(
|
||||
axum::http::header::COOKIE,
|
||||
"yoi_workspace_session=browser-session-auth",
|
||||
)
|
||||
.header(ORIGIN, &expected_origin)
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(Body::from("{"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(malformed.status(), StatusCode::BAD_REQUEST);
|
||||
assert!(
|
||||
serde_json::from_slice::<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
|
||||
.clone()
|
||||
.oneshot(create_repository(repository_request.clone()))
|
||||
@@ -22960,6 +23253,93 @@ mod tests {
|
||||
.unwrap();
|
||||
assert!(String::from_utf8_lossy(&listed_body).contains("documentation"));
|
||||
|
||||
let repository_detail_uri = format!("{repositories_uri}/documentation");
|
||||
let detailed = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(repository_detail_uri)
|
||||
.header(
|
||||
axum::http::header::COOKIE,
|
||||
"yoi_workspace_session=browser-session-auth",
|
||||
)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(detailed.status(), StatusCode::OK);
|
||||
let missing = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("{repositories_uri}/missing"))
|
||||
.header(
|
||||
axum::http::header::COOKIE,
|
||||
"yoi_workspace_session=browser-session-auth",
|
||||
)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let missing_status = missing.status();
|
||||
let missing_body = to_bytes(missing.into_body(), usize::MAX).await.unwrap();
|
||||
assert_eq!(
|
||||
missing_status,
|
||||
StatusCode::NOT_FOUND,
|
||||
"{}",
|
||||
String::from_utf8_lossy(&missing_body)
|
||||
);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let api_base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let network_app = app.clone();
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, network_app).await.unwrap();
|
||||
});
|
||||
let generated = server_api::ServerApiClient::builder(&api_base_url)
|
||||
.unwrap()
|
||||
.authorizer(TestBearerAuthorizer("api-token-auth"))
|
||||
.build()
|
||||
.unwrap();
|
||||
let generated_workspace_id = workspace.workspace.workspace_id.clone();
|
||||
let generated_list = generated
|
||||
.repository_list(generated_workspace_id.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
generated_list
|
||||
.items
|
||||
.iter()
|
||||
.any(|repository| repository.repository_key == "documentation")
|
||||
);
|
||||
assert_eq!(
|
||||
generated
|
||||
.repository_detail(generated_workspace_id.clone(), "documentation".to_owned())
|
||||
.await
|
||||
.unwrap()
|
||||
.item
|
||||
.repository_key,
|
||||
"documentation"
|
||||
);
|
||||
let generated_request = CreateWorkspaceRepositoryRequest {
|
||||
repository_key: "generated-client".to_owned(),
|
||||
source: "/srv/repos/generated-client".to_owned(),
|
||||
default_ref: Some("main".to_owned()),
|
||||
};
|
||||
let created = generated
|
||||
.repository_create(generated_workspace_id.clone(), generated_request.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!created.replayed);
|
||||
let replayed = generated
|
||||
.repository_create(generated_workspace_id, generated_request)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(replayed.replayed);
|
||||
server.abort();
|
||||
|
||||
let identity_uri = format!(
|
||||
"/api/w/{}/settings/signing-identity",
|
||||
workspace.workspace.workspace_id
|
||||
@@ -23128,6 +23508,7 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(mixed_auth_csrf_rejected.status(), StatusCode::FORBIDDEN);
|
||||
|
||||
let deleting_app = app.clone();
|
||||
let csrf_accepted = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
@@ -23145,6 +23526,48 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
assert_ne!(csrf_accepted.status(), StatusCode::FORBIDDEN);
|
||||
|
||||
let preflight = store
|
||||
.workspace_deletion_preflight("account-auth", &workspace.workspace.workspace_id)
|
||||
.unwrap();
|
||||
store
|
||||
.reserve_workspace_deletion(
|
||||
"account-auth",
|
||||
&workspace.workspace.workspace_id,
|
||||
&server_api::WorkspaceDeletionRequest {
|
||||
operation_id: "repository-conflict-test".to_owned(),
|
||||
expected_revision: preflight.expected_revision,
|
||||
confirmation: preflight.display_name,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let deleting_conflict = deleting_app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(&repositories_uri)
|
||||
.header(axum::http::header::AUTHORIZATION, "Bearer api-token-auth")
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"repository_key": "blocked-by-deletion",
|
||||
"source": temp.path().join("blocked-by-deletion").display().to_string()
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(deleting_conflict.status(), StatusCode::CONFLICT);
|
||||
let deleting_body: server_api::RepositoryApiError = serde_json::from_slice(
|
||||
&to_bytes(deleting_conflict.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(deleting_body.error, "Conflict");
|
||||
assert!(deleting_body.message.contains("deleting"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user