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