Complete repository contract error coverage
This commit is contained in:
@@ -106,6 +106,10 @@ struct RouteArgs {
|
|||||||
status: Option<LitInt>,
|
status: Option<LitInt>,
|
||||||
alternate_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>,
|
openapi: Option<syn::LitBool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +122,10 @@ impl Parse for RouteArgs {
|
|||||||
status: None,
|
status: None,
|
||||||
alternate_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,
|
openapi: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -148,6 +156,40 @@ impl Parse for RouteArgs {
|
|||||||
&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(
|
"openapi" => set_once(
|
||||||
&mut result.openapi,
|
&mut result.openapi,
|
||||||
input.parse::<syn::LitBool>()?,
|
input.parse::<syn::LitBool>()?,
|
||||||
@@ -223,6 +265,10 @@ struct Operation {
|
|||||||
response_status: u16,
|
response_status: u16,
|
||||||
alternate_status: Option<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,
|
openapi_skip: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,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(
|
||||||
@@ -585,6 +659,13 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result<Operation> {
|
|||||||
response_status,
|
response_status,
|
||||||
alternate_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),
|
openapi_skip: route.openapi.as_ref().is_some_and(|value| !value.value),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1245,14 +1326,49 @@ fn axum_adapter_tokens(
|
|||||||
let ty = ¶meter.ty;
|
let ty = ¶meter.ty;
|
||||||
Some(quote!(#api_crate::axum::framework::Extension(#ident): #api_crate::axum::framework::Extension<#ty>,))
|
Some(quote!(#api_crate::axum::framework::Extension(#ident): #api_crate::axum::framework::Extension<#ty>,))
|
||||||
});
|
});
|
||||||
let body_extractor = operation.parameters.iter().find_map(|parameter| {
|
let (body_extractor, body_rejection) = operation
|
||||||
if !matches!(parameter.location, Location::Body) {
|
.parameters
|
||||||
return None;
|
.iter()
|
||||||
}
|
.find(|parameter| matches!(parameter.location, Location::Body))
|
||||||
let ident = ¶meter.rust_ident;
|
.map(|parameter| {
|
||||||
let ty = ¶meter.ty;
|
let ident = ¶meter.rust_ident;
|
||||||
Some(quote!(#api_crate::axum::framework::Json(#ident): #api_crate::axum::framework::Json<#ty>,))
|
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 let Some(alternate_status) = operation.alternate_status {
|
let success = if let Some(alternate_status) = operation.alternate_status {
|
||||||
@@ -1312,6 +1428,7 @@ fn axum_adapter_tokens(
|
|||||||
S: super::#trait_ident + Send + Sync + 'static,
|
S: super::#trait_ident + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
#(#header_parsers)*
|
#(#header_parsers)*
|
||||||
|
#body_rejection
|
||||||
#mapped
|
#mapped
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1415,15 +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
|
#alternate_response
|
||||||
#error
|
#error
|
||||||
|
#(#additional_errors)*
|
||||||
operation.finish()?;
|
operation.finish()?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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::{Extension, 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},
|
||||||
|
|||||||
@@ -39,7 +39,11 @@
|
|||||||
//! 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`. `alternate_status` opts a JSON response into [`HttpSuccess`] when two successful HTTP
|
//! `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`.
|
//! 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]`, `#[path]`, or `#[extension]`.
|
//! Arguments are classified with `#[body]`, `#[query]`, `#[header]`, `#[path]`, or `#[extension]`.
|
||||||
//! Extension values are trusted server-local Axum context: generated clients and OpenAPI omit them.
|
//! Extension values are trusted server-local Axum context: generated clients and OpenAPI omit them.
|
||||||
@@ -211,6 +215,11 @@ pub trait HttpSuccess {
|
|||||||
fn status_code(&self) -> u16;
|
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
|
||||||
|
|||||||
@@ -69,6 +69,29 @@ impl From<server_api::client_support::ClientError<server_api::RepositoryApiError
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)]
|
#[derive(Clone, Debug)]
|
||||||
struct RepositoryBearerAuthorizer {
|
struct RepositoryBearerAuthorizer {
|
||||||
authorization: String,
|
authorization: String,
|
||||||
@@ -144,7 +167,10 @@ async fn list_backend_workspace_repositories_with_client(
|
|||||||
.response_body_limit(REPOSITORY_RESPONSE_LIMIT)
|
.response_body_limit(REPOSITORY_RESPONSE_LIMIT)
|
||||||
.build()
|
.build()
|
||||||
.map_err(|error| BackendWorkspaceClientError::InvalidTarget(error.to_string()))?;
|
.map_err(|error| BackendWorkspaceClientError::InvalidTarget(error.to_string()))?;
|
||||||
let response = client.repository_list(workspace_id.to_owned()).await?;
|
let response = client
|
||||||
|
.repository_list(workspace_id.to_owned())
|
||||||
|
.await
|
||||||
|
.map_err(|error| repository_client_error(backend, error))?;
|
||||||
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(),
|
||||||
@@ -255,6 +281,92 @@ mod tests {
|
|||||||
handle.join().unwrap();
|
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 {
|
||||||
|
|||||||
@@ -1,16 +1,54 @@
|
|||||||
use std::{env, fs, path::PathBuf};
|
use std::{env, fs, io, path::PathBuf};
|
||||||
|
|
||||||
|
const DEFAULT_OUTPUT: &str = "openapi/server-api.json";
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let output = env::args_os()
|
let mut arguments = env::args_os().skip(1);
|
||||||
.nth(1)
|
let first = arguments.next();
|
||||||
.map(PathBuf::from)
|
let (mode, output) = match first.as_deref().and_then(|value| value.to_str()) {
|
||||||
.unwrap_or_else(|| PathBuf::from("openapi/server-api.json"));
|
Some("--check") => ("check", arguments.next().map(PathBuf::from)),
|
||||||
let document = server_api::canonical_openapi_document()?;
|
Some("--stdout") => ("stdout", None),
|
||||||
let json = document.to_json()?;
|
Some(_) => ("write", first.map(PathBuf::from)),
|
||||||
if let Some(parent) = output.parent() {
|
None => ("write", None),
|
||||||
fs::create_dir_all(parent)?;
|
};
|
||||||
|
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!(),
|
||||||
}
|
}
|
||||||
fs::write(&output, json)?;
|
|
||||||
println!("wrote {}", output.display());
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
+108
-13
@@ -56,6 +56,18 @@ impl api_macros::HttpError for RepositoryApiError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
macro_rules! impl_openapi_schema {
|
||||||
($($ty:ty),+ $(,)?) => {
|
($($ty:ty),+ $(,)?) => {
|
||||||
$(impl api_macros::openapi::OpenApiSchema for $ty {})+
|
$(impl api_macros::openapi::OpenApiSchema for $ty {})+
|
||||||
@@ -92,19 +104,36 @@ pub trait ServerApi {
|
|||||||
#[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)]
|
#[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(
|
async fn repository_list(
|
||||||
&self,
|
&self,
|
||||||
#[path] workspace_id: String,
|
#[path] workspace_id: String,
|
||||||
) -> Result<RepositoryListResponse, RepositoryApiError>;
|
) -> Result<RepositoryListResponse, RepositoryApiError>;
|
||||||
|
|
||||||
#[get("/api/repositories", status = 200, error_status = 400)]
|
#[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>;
|
async fn repository_list_alias(&self) -> Result<RepositoryListResponse, RepositoryApiError>;
|
||||||
|
|
||||||
#[get(
|
#[get(
|
||||||
"/api/w/{workspace_id}/repositories/{repository_key}",
|
"/api/w/{workspace_id}/repositories/{repository_key}",
|
||||||
status = 200,
|
status = 200,
|
||||||
error_status = 404
|
error_status = 404,
|
||||||
|
additional_error_statuses = [400, 401, 403, 500],
|
||||||
|
bearer_auth = true,
|
||||||
|
browser_auth = true
|
||||||
)]
|
)]
|
||||||
async fn repository_detail(
|
async fn repository_detail(
|
||||||
&self,
|
&self,
|
||||||
@@ -112,7 +141,14 @@ pub trait ServerApi {
|
|||||||
#[path] repository_key: String,
|
#[path] repository_key: String,
|
||||||
) -> Result<RepositoryDetailResponse, RepositoryApiError>;
|
) -> Result<RepositoryDetailResponse, RepositoryApiError>;
|
||||||
|
|
||||||
#[get("/api/repositories/{repository_key}", status = 200, error_status = 404)]
|
#[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(
|
async fn repository_detail_alias(
|
||||||
&self,
|
&self,
|
||||||
#[path] repository_key: String,
|
#[path] repository_key: String,
|
||||||
@@ -122,7 +158,11 @@ pub trait ServerApi {
|
|||||||
"/api/w/{workspace_id}/repositories",
|
"/api/w/{workspace_id}/repositories",
|
||||||
status = 201,
|
status = 201,
|
||||||
alternate_status = 200,
|
alternate_status = 200,
|
||||||
error_status = 400
|
error_status = 400,
|
||||||
|
additional_error_statuses = [401, 403, 404, 409, 413, 422, 500],
|
||||||
|
bearer_auth = true,
|
||||||
|
browser_auth = true,
|
||||||
|
normalize_body_errors = true
|
||||||
)]
|
)]
|
||||||
async fn repository_create(
|
async fn repository_create(
|
||||||
&self,
|
&self,
|
||||||
@@ -132,9 +172,19 @@ pub trait ServerApi {
|
|||||||
) -> Result<CreateWorkspaceRepositoryResponse, RepositoryApiError>;
|
) -> Result<CreateWorkspaceRepositoryResponse, RepositoryApiError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Digest of the authoritative ServerApi Rust contract source used for artifact provenance.
|
/// Digest of every authoritative input used to derive the canonical OpenAPI artifact.
|
||||||
pub fn canonical_openapi_source_digest() -> String {
|
pub fn canonical_openapi_source_digest() -> String {
|
||||||
let digest = Sha256::digest(include_bytes!("lib.rs"));
|
let mut hasher = Sha256::new();
|
||||||
|
for source in [
|
||||||
|
include_bytes!("lib.rs").as_slice(),
|
||||||
|
include_bytes!("../../api-macros-impl/src/lib.rs").as_slice(),
|
||||||
|
include_bytes!("../../api-macros/src/lib.rs").as_slice(),
|
||||||
|
include_bytes!("../../api-macros/src/openapi.rs").as_slice(),
|
||||||
|
] {
|
||||||
|
hasher.update((source.len() as u64).to_le_bytes());
|
||||||
|
hasher.update(source);
|
||||||
|
}
|
||||||
|
let digest = hasher.finalize();
|
||||||
let mut encoded = String::with_capacity("sha256:".len() + digest.len() * 2);
|
let mut encoded = String::with_capacity("sha256:".len() + digest.len() * 2);
|
||||||
encoded.push_str("sha256:");
|
encoded.push_str("sha256:");
|
||||||
for byte in digest {
|
for byte in digest {
|
||||||
@@ -5154,13 +5204,58 @@ mod openapi_artifact_tests {
|
|||||||
);
|
);
|
||||||
assert!(value["paths"]["/api/repositories"]["get"].is_object());
|
assert!(value["paths"]["/api/repositories"]["get"].is_object());
|
||||||
assert!(value["paths"]["/api/repositories/{repository_key}"]["get"].is_object());
|
assert!(value["paths"]["/api/repositories/{repository_key}"]["get"].is_object());
|
||||||
assert!(
|
for (path, method) in [
|
||||||
value["paths"]["/api/w/{workspace_id}/repositories"]["post"]["responses"]["200"]
|
("/api/w/{workspace_id}/repositories", "get"),
|
||||||
.is_object()
|
("/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!(
|
assert_eq!(
|
||||||
value["paths"]["/api/w/{workspace_id}/repositories"]["post"]["responses"]["201"]
|
repository_create["security"][1]["browserSession"],
|
||||||
.is_object()
|
serde_json::json!([])
|
||||||
|
);
|
||||||
|
for status in [
|
||||||
|
"200", "201", "400", "401", "403", "404", "409", "413", "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}"]
|
assert!(value["paths"]["/api/w/{workspace_id}/repositories/{repository_key}"]
|
||||||
["get"]["responses"]["404"]
|
["get"]["responses"]["404"]
|
||||||
|
|||||||
@@ -1604,11 +1604,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)
|
||||||
@@ -1650,7 +1670,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(),
|
||||||
@@ -1674,7 +1700,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!(
|
||||||
@@ -1690,7 +1722,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);
|
||||||
@@ -1719,6 +1755,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
|
||||||
@@ -1762,7 +1799,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!(
|
||||||
@@ -1795,7 +1836,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!(
|
||||||
@@ -1811,7 +1858,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);
|
||||||
@@ -1939,7 +1990,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,
|
||||||
@@ -18844,38 +18895,8 @@ impl ApiError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn into_repository_api_error(self) -> server_api::RepositoryApiError {
|
fn into_repository_api_error(self) -> server_api::RepositoryApiError {
|
||||||
let status = match &self.error {
|
let status = api_error_status(&self.error);
|
||||||
Error::WorkspacePermissionDenied(_) | Error::BrowserReopenConfirmationRequired => {
|
let message = api_error_response_message(&self.error);
|
||||||
StatusCode::FORBIDDEN
|
|
||||||
}
|
|
||||||
Error::RepositoryConflict(_) => StatusCode::CONFLICT,
|
|
||||||
Error::InvalidInput(_)
|
|
||||||
| Error::WorkerSourceIdentity(_)
|
|
||||||
| Error::InvalidRuntimeIdentifier { .. }
|
|
||||||
| Error::ReservedWorkerName(_) => StatusCode::BAD_REQUEST,
|
|
||||||
Error::RuntimeOperationFailed { code, .. } if code == "repository_not_configured" => {
|
|
||||||
StatusCode::NOT_FOUND
|
|
||||||
}
|
|
||||||
Error::RuntimeOperationFailed { code, .. }
|
|
||||||
if code == "repository_provider_unsupported" =>
|
|
||||||
{
|
|
||||||
StatusCode::BAD_REQUEST
|
|
||||||
}
|
|
||||||
Error::InvalidRecordId(_)
|
|
||||||
| Error::MissingFrontmatter(_)
|
|
||||||
| Error::UnknownRepository(_)
|
|
||||||
| Error::UnknownRuntime(_)
|
|
||||||
| Error::UnknownWorker { .. }
|
|
||||||
| Error::RuntimeBindingNotFound { .. }
|
|
||||||
| Error::UnknownHost(_)
|
|
||||||
| Error::WorkspaceIdMismatch => StatusCode::NOT_FOUND,
|
|
||||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
};
|
|
||||||
let message = if status == StatusCode::INTERNAL_SERVER_ERROR {
|
|
||||||
"internal server error".to_owned()
|
|
||||||
} else {
|
|
||||||
self.error.to_string()
|
|
||||||
};
|
|
||||||
server_api::RepositoryApiError::new(
|
server_api::RepositoryApiError::new(
|
||||||
status.as_u16(),
|
status.as_u16(),
|
||||||
status.canonical_reason().unwrap_or("error"),
|
status.canonical_reason().unwrap_or("error"),
|
||||||
@@ -18885,125 +18906,129 @@ impl ApiError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
@@ -22881,6 +22906,93 @@ 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 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()))
|
||||||
|
|||||||
+287
-6
@@ -337,12 +337,23 @@
|
|||||||
"string": {
|
"string": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"securitySchemes": {
|
||||||
|
"bearerAuth": {
|
||||||
|
"scheme": "bearer",
|
||||||
|
"type": "http"
|
||||||
|
},
|
||||||
|
"browserSession": {
|
||||||
|
"in": "cookie",
|
||||||
|
"name": "yoi_workspace_session",
|
||||||
|
"type": "apiKey"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
"title": "Yoi Server API",
|
"title": "Yoi Server API",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"x-yoi-source-digest": "sha256:317cc65b39674bd229abc364d2daea3e909215471da5828313b3cf3ee2142600"
|
"x-yoi-source-digest": "sha256:9ba281f960b7a6bf8f1e8e29c1a5821ab5d8808f53a7b0f98abe76832e658189"
|
||||||
},
|
},
|
||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"paths": {
|
"paths": {
|
||||||
@@ -369,8 +380,56 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "Error response"
|
"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}": {
|
"/api/repositories/{repository_key}": {
|
||||||
@@ -399,6 +458,36 @@
|
|||||||
},
|
},
|
||||||
"description": "Successful response"
|
"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": {
|
"404": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
@@ -408,8 +497,26 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "Error response"
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"browserSession": []
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/w/{workspace_id}/repositories": {
|
"/api/w/{workspace_id}/repositories": {
|
||||||
@@ -447,8 +554,56 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "Error response"
|
"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": {
|
"post": {
|
||||||
"operationId": "repository_create",
|
"operationId": "repository_create",
|
||||||
@@ -504,8 +659,86 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "Error response"
|
"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"
|
||||||
|
},
|
||||||
|
"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}": {
|
"/api/w/{workspace_id}/repositories/{repository_key}": {
|
||||||
@@ -544,6 +777,36 @@
|
|||||||
},
|
},
|
||||||
"description": "Successful response"
|
"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": {
|
"404": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
@@ -553,8 +816,26 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "Error response"
|
"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