Migrate repository routes to ServerApi
This commit is contained in:
Generated
+2
@@ -4157,8 +4157,10 @@ dependencies = [
|
||||
"api-macros",
|
||||
"protocol",
|
||||
"runtime-api",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tokio",
|
||||
"ts-rs",
|
||||
"webauthn-rs-proto",
|
||||
|
||||
@@ -104,7 +104,9 @@ struct RouteArgs {
|
||||
path: LitStr,
|
||||
operation_id: Option<LitStr>,
|
||||
status: Option<LitInt>,
|
||||
alternate_status: Option<LitInt>,
|
||||
error_status: Option<LitInt>,
|
||||
openapi: Option<syn::LitBool>,
|
||||
}
|
||||
|
||||
impl Parse for RouteArgs {
|
||||
@@ -114,7 +116,9 @@ impl Parse for RouteArgs {
|
||||
path,
|
||||
operation_id: None,
|
||||
status: None,
|
||||
alternate_status: None,
|
||||
error_status: None,
|
||||
openapi: None,
|
||||
};
|
||||
|
||||
while !input.is_empty() {
|
||||
@@ -132,12 +136,24 @@ impl Parse for RouteArgs {
|
||||
"operation_id",
|
||||
)?,
|
||||
"status" => set_once(&mut result.status, input.parse::<LitInt>()?, &key, "status")?,
|
||||
"alternate_status" => set_once(
|
||||
&mut result.alternate_status,
|
||||
input.parse::<LitInt>()?,
|
||||
&key,
|
||||
"alternate_status",
|
||||
)?,
|
||||
"error_status" => set_once(
|
||||
&mut result.error_status,
|
||||
input.parse::<LitInt>()?,
|
||||
&key,
|
||||
"error_status",
|
||||
)?,
|
||||
"openapi" => set_once(
|
||||
&mut result.openapi,
|
||||
input.parse::<syn::LitBool>()?,
|
||||
&key,
|
||||
"openapi",
|
||||
)?,
|
||||
_ => {
|
||||
return Err(syn::Error::new(
|
||||
key.span(),
|
||||
@@ -167,6 +183,9 @@ enum Location {
|
||||
Query,
|
||||
Header,
|
||||
Body,
|
||||
/// Trusted request-local context supplied by the server adapter. Extension
|
||||
/// parameters are not part of the wire contract or generated client signature.
|
||||
Extension,
|
||||
}
|
||||
|
||||
impl Location {
|
||||
@@ -176,6 +195,7 @@ impl Location {
|
||||
Self::Query => quote!(Query),
|
||||
Self::Header => quote!(Header),
|
||||
Self::Body => quote!(Body),
|
||||
Self::Extension => unreachable!("extensions are not wire parameters"),
|
||||
};
|
||||
quote!(#api_crate::ParameterLocation::#variant)
|
||||
}
|
||||
@@ -201,7 +221,9 @@ struct Operation {
|
||||
error_body: Option<Type>,
|
||||
fallible: bool,
|
||||
response_status: u16,
|
||||
alternate_status: Option<u16>,
|
||||
error_status: Option<u16>,
|
||||
openapi_skip: bool,
|
||||
}
|
||||
|
||||
struct ApiDefinition {
|
||||
@@ -392,6 +414,21 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result<Operation> {
|
||||
"status must be a success status between 200 and 299",
|
||||
));
|
||||
}
|
||||
let alternate_status = parse_status(route.alternate_status.as_ref(), "alternate_status")?;
|
||||
if let Some(status) = alternate_status
|
||||
&& (!(200..=299).contains(&status) || status == response_status)
|
||||
{
|
||||
return Err(syn::Error::new_spanned(
|
||||
route.alternate_status,
|
||||
"alternate_status must be a distinct success status between 200 and 299",
|
||||
));
|
||||
}
|
||||
if alternate_status.is_some() && response_body.is_none() {
|
||||
return Err(syn::Error::new_spanned(
|
||||
route.alternate_status,
|
||||
"alternate_status requires a response body implementing HttpSuccess",
|
||||
));
|
||||
}
|
||||
let error_status = parse_status(route.error_status.as_ref(), "error_status")?;
|
||||
if error_status.is_some() && error_body.is_none() {
|
||||
return Err(syn::Error::new_spanned(
|
||||
@@ -464,11 +501,20 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result<Operation> {
|
||||
}
|
||||
(Location::Body, rust_name.clone())
|
||||
}
|
||||
Some((Location::Extension, wire_name)) => {
|
||||
if wire_name.is_some() {
|
||||
return Err(syn::Error::new(
|
||||
ident.span(),
|
||||
"#[extension] does not accept a wire name",
|
||||
));
|
||||
}
|
||||
(Location::Extension, rust_name.clone())
|
||||
}
|
||||
None if placeholders.contains(&rust_name) => (Location::Path, rust_name.clone()),
|
||||
None => {
|
||||
return Err(syn::Error::new(
|
||||
ident.span(),
|
||||
"API arguments must use #[path], #[query], #[header], or #[body]; path arguments may omit #[path] when their name matches a placeholder",
|
||||
"API arguments must use #[path], #[query], #[header], #[body], or #[extension]; path arguments may omit #[path] when their name matches a placeholder",
|
||||
));
|
||||
}
|
||||
};
|
||||
@@ -537,7 +583,9 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result<Operation> {
|
||||
error_body,
|
||||
fallible,
|
||||
response_status,
|
||||
alternate_status,
|
||||
error_status,
|
||||
openapi_skip: route.openapi.as_ref().is_some_and(|value| !value.value),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -588,6 +636,7 @@ fn take_location(attrs: &mut Vec<Attribute>) -> syn::Result<Option<(Location, Op
|
||||
Some("query") => Some(Location::Query),
|
||||
Some("header") => Some(Location::Header),
|
||||
Some("body") => Some(Location::Body),
|
||||
Some("extension") => Some(Location::Extension),
|
||||
Some("stream") | Some("multipart") | Some("binary") => {
|
||||
return Err(syn::Error::new_spanned(
|
||||
attr,
|
||||
@@ -881,10 +930,13 @@ fn reqwest_adapter_tokens(
|
||||
let builder_ident = format_ident!("{}ClientBuilder", ident_text(trait_ident));
|
||||
let methods = api.operations.iter().map(|operation| {
|
||||
let method_ident = &operation.method_ident;
|
||||
let arguments = operation.parameters.iter().map(|parameter| {
|
||||
let arguments = operation.parameters.iter().filter_map(|parameter| {
|
||||
if matches!(parameter.location, Location::Extension) {
|
||||
return None;
|
||||
}
|
||||
let ident = ¶meter.rust_ident;
|
||||
let ty = ¶meter.ty;
|
||||
quote!(#ident: #ty)
|
||||
Some(quote!(#ident: #ty))
|
||||
});
|
||||
let path_segments = operation
|
||||
.path
|
||||
@@ -928,6 +980,10 @@ fn reqwest_adapter_tokens(
|
||||
.unwrap_or_else(|| quote!(::core::option::Option::None));
|
||||
let method = reqwest_method_tokens(operation.method, api_crate);
|
||||
let response_status = operation.response_status;
|
||||
let accepted_status = operation
|
||||
.alternate_status
|
||||
.map(|alternate| quote!(__actual == #response_status || __actual == #alternate))
|
||||
.unwrap_or_else(|| quote!(__actual == #response_status));
|
||||
let response_type = operation
|
||||
.response_body
|
||||
.as_ref()
|
||||
@@ -985,7 +1041,7 @@ fn reqwest_adapter_tokens(
|
||||
.await
|
||||
.map_err(#api_crate::reqwest::ClientError::from)?;
|
||||
let __actual = __response.status().as_u16();
|
||||
if __actual == #response_status {
|
||||
if #accepted_status {
|
||||
#success
|
||||
}
|
||||
#public_error
|
||||
@@ -1181,6 +1237,14 @@ fn axum_adapter_tokens(
|
||||
};
|
||||
})
|
||||
});
|
||||
let extension_extractors = operation.parameters.iter().filter_map(|parameter| {
|
||||
if !matches!(parameter.location, Location::Extension) {
|
||||
return None;
|
||||
}
|
||||
let ident = ¶meter.rust_ident;
|
||||
let ty = ¶meter.ty;
|
||||
Some(quote!(#api_crate::axum::framework::Extension(#ident): #api_crate::axum::framework::Extension<#ty>,))
|
||||
});
|
||||
let body_extractor = operation.parameters.iter().find_map(|parameter| {
|
||||
if !matches!(parameter.location, Location::Body) {
|
||||
return None;
|
||||
@@ -1191,7 +1255,16 @@ fn axum_adapter_tokens(
|
||||
});
|
||||
let call_arguments = operation.parameters.iter().map(|parameter| ¶meter.rust_ident);
|
||||
let response_status = operation.response_status;
|
||||
let success = if operation.response_body.is_some() {
|
||||
let success = if let Some(alternate_status) = operation.alternate_status {
|
||||
quote!({
|
||||
let status = #api_crate::HttpSuccess::status_code(&value);
|
||||
if status == #response_status || status == #alternate_status {
|
||||
#api_crate::axum::json_response(#api_crate::axum::status(status), value)
|
||||
} else {
|
||||
#api_crate::axum::empty_response(#api_crate::axum::status(500))
|
||||
}
|
||||
})
|
||||
} else if operation.response_body.is_some() {
|
||||
quote!(#api_crate::axum::json_response(#api_crate::axum::status(#response_status), value))
|
||||
} else {
|
||||
quote!({ let _ = value; #api_crate::axum::empty_response(#api_crate::axum::status(#response_status)) })
|
||||
@@ -1232,6 +1305,7 @@ fn axum_adapter_tokens(
|
||||
#path_extractor
|
||||
#(#query_extractors)*
|
||||
#header_extractor
|
||||
#(#extension_extractors)*
|
||||
#body_extractor
|
||||
) -> #api_crate::axum::framework::Response
|
||||
where
|
||||
@@ -1285,12 +1359,16 @@ fn openapi_adapter_tokens(
|
||||
to_snake_case(&api.item.ident),
|
||||
span = api.item.ident.span()
|
||||
);
|
||||
let operations = api.operations.iter().map(|operation| {
|
||||
let operations = api
|
||||
.operations
|
||||
.iter()
|
||||
.filter(|operation| !operation.openapi_skip)
|
||||
.map(|operation| {
|
||||
let method = method_name(operation.method);
|
||||
let path = &operation.path;
|
||||
let operation_id = &operation.operation_id;
|
||||
let parameters = operation.parameters.iter().filter_map(|parameter| {
|
||||
if matches!(parameter.location, Location::Body) {
|
||||
if matches!(parameter.location, Location::Body | Location::Extension) {
|
||||
return None;
|
||||
}
|
||||
let ty = ¶meter.ty;
|
||||
@@ -1299,7 +1377,7 @@ fn openapi_adapter_tokens(
|
||||
Location::Path => "path",
|
||||
Location::Query => "query",
|
||||
Location::Header => "header",
|
||||
Location::Body => unreachable!(),
|
||||
Location::Body | Location::Extension => unreachable!(),
|
||||
};
|
||||
Some(quote! {
|
||||
operation.parameter::<#ty>(#name, #location)?;
|
||||
@@ -1319,6 +1397,16 @@ fn openapi_adapter_tokens(
|
||||
operation.empty_response(#status, "Successful response")?;
|
||||
},
|
||||
};
|
||||
let alternate_response = operation.alternate_status.map(|status| {
|
||||
match operation.response_body.as_ref() {
|
||||
Some(ty) => quote! {
|
||||
operation.response::<#ty>(#status, "application/json", "Alternate successful response")?;
|
||||
},
|
||||
None => quote! {
|
||||
operation.empty_response(#status, "Alternate successful response")?;
|
||||
},
|
||||
}
|
||||
});
|
||||
let error = operation
|
||||
.error_status
|
||||
.zip(operation.error_body.as_ref())
|
||||
@@ -1334,6 +1422,7 @@ fn openapi_adapter_tokens(
|
||||
#(#parameters)*
|
||||
#request
|
||||
#response
|
||||
#alternate_response
|
||||
#error
|
||||
operation.finish()?;
|
||||
}
|
||||
@@ -1383,7 +1472,13 @@ fn expand_api(api: ApiDefinition) -> syn::Result<proc_macro2::TokenStream> {
|
||||
.map(|operation| {
|
||||
let marker_ident = &operation.marker_ident;
|
||||
let metadata = metadata_tokens(operation, &api_crate);
|
||||
let parameter_types = operation.parameters.iter().map(|parameter| ¶meter.ty);
|
||||
let parameter_types = operation.parameters.iter().filter_map(|parameter| {
|
||||
if matches!(parameter.location, Location::Extension) {
|
||||
None
|
||||
} else {
|
||||
Some(¶meter.ty)
|
||||
}
|
||||
});
|
||||
let request_type = operation
|
||||
.request_body
|
||||
.as_ref()
|
||||
@@ -1472,17 +1567,20 @@ fn metadata_tokens(
|
||||
},
|
||||
None => quote!(::core::option::Option::None),
|
||||
};
|
||||
let parameters = operation.parameters.iter().map(|parameter| {
|
||||
let parameters = operation.parameters.iter().filter_map(|parameter| {
|
||||
if matches!(parameter.location, Location::Extension) {
|
||||
return None;
|
||||
}
|
||||
let rust_name = ¶meter.rust_name;
|
||||
let wire_name = ¶meter.wire_name;
|
||||
let location = parameter.location.tokens(api_crate);
|
||||
quote! {
|
||||
Some(quote! {
|
||||
#api_crate::ParameterMetadata {
|
||||
rust_name: #rust_name,
|
||||
wire_name: #wire_name,
|
||||
location: #location,
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
quote! {
|
||||
|
||||
@@ -52,7 +52,7 @@ pub fn status(code: u16) -> StatusCode {
|
||||
pub mod framework {
|
||||
pub use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, Query, State},
|
||||
extract::{Extension, Path, Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::Response,
|
||||
routing::{delete, get, head, options, patch, post, put},
|
||||
|
||||
@@ -38,13 +38,17 @@
|
||||
//! `options`. The first argument is a path template. `operation_id` defaults to the Rust
|
||||
//! method name but should be set explicitly for contracts which must remain stable while Rust
|
||||
//! names evolve. Success status defaults to `200`, except an empty (`()`) response defaults to
|
||||
//! `204`. A public error inferred from `Result<T, E>` defaults to status `400`.
|
||||
//! `204`. `alternate_status` opts a JSON response into [`HttpSuccess`] when two successful HTTP
|
||||
//! outcomes share one schema. A public error inferred from `Result<T, E>` defaults to status `400`.
|
||||
//!
|
||||
//! Arguments are classified with `#[body]`, `#[query]`, `#[header]`, or `#[path]`. An
|
||||
//! unannotated argument whose Rust name occurs in the route template is inferred as a path
|
||||
//! Arguments are classified with `#[body]`, `#[query]`, `#[header]`, `#[path]`, or `#[extension]`.
|
||||
//! Extension values are trusted server-local Axum context: generated clients and OpenAPI omit them.
|
||||
//! An unannotated argument whose Rust name occurs in the route template is inferred as a path
|
||||
//! argument. Header attributes may carry a wire name, as in `#[header("x-request-id")]`.
|
||||
//! Exactly one JSON body is allowed. JSON request, response, and error bodies must be named
|
||||
//! Rust types; tuples, references, arrays, and other anonymous structural types are rejected.
|
||||
//! `openapi = false` explicitly excludes an operation whose wire body cannot yet satisfy the
|
||||
//! strict OpenAPI schema boundary; Reqwest and Axum adapters are still generated.
|
||||
//!
|
||||
//! # Generated names
|
||||
//!
|
||||
@@ -200,6 +204,13 @@ pub trait HttpError {
|
||||
fn status_code(&self) -> u16;
|
||||
}
|
||||
|
||||
/// Successful JSON response that selects one of an operation's declared success statuses.
|
||||
///
|
||||
/// Operations opt in with `alternate_status`; ordinary one-status operations remain fully static.
|
||||
pub trait HttpSuccess {
|
||||
fn status_code(&self) -> u16;
|
||||
}
|
||||
|
||||
/// Marker used when an operation has no request, response, or public error body.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum NoBody {}
|
||||
|
||||
@@ -192,6 +192,10 @@ impl BackendApiClient {
|
||||
format!("Bearer {}", self.access_token.0)
|
||||
}
|
||||
|
||||
pub(crate) fn asynchronous_client(&self) -> reqwest::Client {
|
||||
self.asynchronous.clone()
|
||||
}
|
||||
|
||||
pub async fn require_success(
|
||||
&self,
|
||||
response: reqwest::Response,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
use crate::{BackendApiClient, BackendApiClientError};
|
||||
use reqwest::Method;
|
||||
use server_api::{
|
||||
InitialRepositoryIntent, RepositoryListResponse, RepositorySummary,
|
||||
WorkspaceCatalogListResponse, WorkspaceCreateRequest, WorkspaceCreateResponse,
|
||||
WorkspaceSummary,
|
||||
InitialRepositoryIntent, RepositorySummary, WorkspaceCatalogListResponse,
|
||||
WorkspaceCreateRequest, WorkspaceCreateResponse, WorkspaceSummary,
|
||||
};
|
||||
use std::fmt;
|
||||
|
||||
@@ -32,6 +31,7 @@ pub enum BackendWorkspaceClientError {
|
||||
InvalidTarget(String),
|
||||
Api(BackendApiClientError),
|
||||
Http(reqwest::Error),
|
||||
RepositoryApi(server_api::client_support::ClientError<server_api::RepositoryApiError>),
|
||||
}
|
||||
|
||||
impl fmt::Display for BackendWorkspaceClientError {
|
||||
@@ -40,6 +40,7 @@ impl fmt::Display for BackendWorkspaceClientError {
|
||||
Self::InvalidTarget(message) => f.write_str(message),
|
||||
Self::Api(error) => write!(f, "{error}"),
|
||||
Self::Http(error) => write!(f, "{error}"),
|
||||
Self::RepositoryApi(error) => write!(f, "{error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,6 +59,39 @@ impl From<reqwest::Error> for BackendWorkspaceClientError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<server_api::client_support::ClientError<server_api::RepositoryApiError>>
|
||||
for BackendWorkspaceClientError
|
||||
{
|
||||
fn from(
|
||||
error: server_api::client_support::ClientError<server_api::RepositoryApiError>,
|
||||
) -> Self {
|
||||
Self::RepositoryApi(error)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct RepositoryBearerAuthorizer {
|
||||
authorization: String,
|
||||
}
|
||||
|
||||
impl server_api::client_support::RequestAuthorizer for RepositoryBearerAuthorizer {
|
||||
fn authorize(
|
||||
&self,
|
||||
_request: server_api::client_support::AuthorizerRequest<'_>,
|
||||
) -> Result<reqwest::header::HeaderMap, server_api::client_support::AuthorizationError> {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
reqwest::header::AUTHORIZATION,
|
||||
self.authorization
|
||||
.parse()
|
||||
.map_err(|_| server_api::client_support::AuthorizationError::new())?,
|
||||
);
|
||||
Ok(headers)
|
||||
}
|
||||
}
|
||||
|
||||
const REPOSITORY_RESPONSE_LIMIT: usize = 16 * 1024 * 1024;
|
||||
|
||||
pub fn list_backend_workspaces_blocking(
|
||||
target: &BackendWorkspaceCatalogTarget,
|
||||
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
|
||||
@@ -75,6 +109,21 @@ pub fn list_backend_workspaces_blocking(
|
||||
pub fn list_backend_workspace_repositories_blocking(
|
||||
target: &BackendWorkspaceCatalogTarget,
|
||||
workspace_id: &str,
|
||||
) -> Result<Vec<RepositorySummary>, BackendWorkspaceClientError> {
|
||||
let client = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|error| BackendWorkspaceClientError::InvalidTarget(error.to_string()))?;
|
||||
runtime.block_on(list_backend_workspace_repositories_with_client(
|
||||
&client,
|
||||
workspace_id,
|
||||
))
|
||||
}
|
||||
|
||||
async fn list_backend_workspace_repositories_with_client(
|
||||
backend: &BackendApiClient,
|
||||
workspace_id: &str,
|
||||
) -> Result<Vec<RepositorySummary>, BackendWorkspaceClientError> {
|
||||
if workspace_id.is_empty()
|
||||
|| workspace_id.len() > 200
|
||||
@@ -86,12 +135,16 @@ pub fn list_backend_workspace_repositories_blocking(
|
||||
"Workspace id returned by Backend is invalid".to_string(),
|
||||
));
|
||||
}
|
||||
let client = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||
let response = client
|
||||
.blocking_request(Method::GET, &format!("/api/w/{workspace_id}/repositories"))?
|
||||
.send()?;
|
||||
client.check_status(response.status())?;
|
||||
let response = response.json::<RepositoryListResponse>()?;
|
||||
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.repository_list(workspace_id.to_owned()).await?;
|
||||
if response.workspace_id != workspace_id {
|
||||
return Err(BackendWorkspaceClientError::InvalidTarget(
|
||||
"Repository catalog response does not match the requested Workspace".to_string(),
|
||||
|
||||
@@ -10,10 +10,12 @@ default = []
|
||||
typescript = ["dep:ts-rs", "protocol/typescript"]
|
||||
|
||||
[dependencies]
|
||||
api-macros = { workspace = true, features = ["reqwest", "axum"] }
|
||||
api-macros = { workspace = true, features = ["reqwest", "axum", "openapi"] }
|
||||
protocol.workspace = true
|
||||
runtime-api.workspace = true
|
||||
schemars.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
sha2.workspace = true
|
||||
ts-rs = { version = "12.0.1", optional = true }
|
||||
webauthn-rs-proto = "0.5.5"
|
||||
workdir.workspace = true
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
use std::{env, fs, path::PathBuf};
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let output = env::args_os()
|
||||
.nth(1)
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("openapi/server-api.json"));
|
||||
let document = server_api::canonical_openapi_document()?;
|
||||
let json = document.to_json()?;
|
||||
if let Some(parent) = output.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(&output, json)?;
|
||||
println!("wrote {}", output.display());
|
||||
Ok(())
|
||||
}
|
||||
+183
-15
@@ -8,7 +8,9 @@ use api_macros::api;
|
||||
pub use api_macros::axum as server_support;
|
||||
pub use api_macros::reqwest as client_support;
|
||||
pub use api_macros::{ApiContract, HttpMethod};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use webauthn_rs_proto::{
|
||||
CreationChallengeResponse, PublicKeyCredential, RegisterPublicKeyCredential,
|
||||
RequestChallengeResponse,
|
||||
@@ -17,6 +19,57 @@ use webauthn_rs_proto::{
|
||||
pub type ServerApiError = runtime_api::RuntimeApiError;
|
||||
pub type ServerApiClientError = client_support::ClientError<ServerApiError>;
|
||||
|
||||
/// Error body shared by the existing repository routes and their generated adapters.
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)]
|
||||
pub struct RepositoryApiError {
|
||||
pub error: String,
|
||||
pub message: String,
|
||||
#[serde(default)]
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
#[serde(skip, default = "default_repository_error_status")]
|
||||
status: u16,
|
||||
}
|
||||
|
||||
const fn default_repository_error_status() -> u16 {
|
||||
400
|
||||
}
|
||||
|
||||
impl RepositoryApiError {
|
||||
pub fn new(
|
||||
status: u16,
|
||||
error: impl Into<String>,
|
||||
message: impl Into<String>,
|
||||
diagnostics: Vec<Diagnostic>,
|
||||
) -> Self {
|
||||
Self {
|
||||
error: error.into(),
|
||||
message: message.into(),
|
||||
diagnostics,
|
||||
status,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl api_macros::HttpError for RepositoryApiError {
|
||||
fn status_code(&self) -> u16 {
|
||||
self.status
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! impl_openapi_schema {
|
||||
($($ty:ty),+ $(,)?) => {
|
||||
$(impl api_macros::openapi::OpenApiSchema for $ty {})+
|
||||
};
|
||||
}
|
||||
|
||||
impl_openapi_schema!(
|
||||
CreateWorkspaceRepositoryRequest,
|
||||
CreateWorkspaceRepositoryResponse,
|
||||
RepositoryApiError,
|
||||
RepositoryListResponse,
|
||||
RepositoryDetailResponse,
|
||||
);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct WorkspaceWorkerSessionResponse {
|
||||
pub subject: WorkspaceWorkerSubject,
|
||||
@@ -24,12 +77,13 @@ pub struct WorkspaceWorkerSessionResponse {
|
||||
pub observation: runtime_api::WorkerSessionAvailability,
|
||||
}
|
||||
|
||||
#[api(reqwest, axum)]
|
||||
#[api(reqwest, axum, openapi)]
|
||||
pub trait ServerApi {
|
||||
#[get(
|
||||
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/session",
|
||||
status = 200,
|
||||
error_status = 400
|
||||
error_status = 400,
|
||||
openapi = false
|
||||
)]
|
||||
async fn worker_session(
|
||||
&self,
|
||||
@@ -37,6 +91,59 @@ pub trait ServerApi {
|
||||
#[path] runtime_id: String,
|
||||
#[path] worker_id: String,
|
||||
) -> Result<WorkspaceWorkerSessionResponse, ServerApiError>;
|
||||
|
||||
#[get("/api/w/{workspace_id}/repositories", status = 200, error_status = 400)]
|
||||
async fn repository_list(
|
||||
&self,
|
||||
#[path] workspace_id: String,
|
||||
) -> Result<RepositoryListResponse, RepositoryApiError>;
|
||||
|
||||
#[get(
|
||||
"/api/w/{workspace_id}/repositories/{repository_key}",
|
||||
status = 200,
|
||||
error_status = 404
|
||||
)]
|
||||
async fn repository_detail(
|
||||
&self,
|
||||
#[path] workspace_id: String,
|
||||
#[path] repository_key: String,
|
||||
) -> Result<RepositoryDetailResponse, RepositoryApiError>;
|
||||
|
||||
#[post(
|
||||
"/api/w/{workspace_id}/repositories",
|
||||
status = 201,
|
||||
alternate_status = 200,
|
||||
error_status = 400
|
||||
)]
|
||||
async fn repository_create(
|
||||
&self,
|
||||
#[extension] actor: RequestActor,
|
||||
#[path] workspace_id: String,
|
||||
#[body] request: CreateWorkspaceRepositoryRequest,
|
||||
) -> Result<CreateWorkspaceRepositoryResponse, RepositoryApiError>;
|
||||
}
|
||||
|
||||
/// Digest of the authoritative ServerApi Rust contract source used for artifact provenance.
|
||||
pub fn canonical_openapi_source_digest() -> String {
|
||||
let digest = Sha256::digest(include_bytes!("lib.rs"));
|
||||
let mut encoded = String::with_capacity("sha256:".len() + digest.len() * 2);
|
||||
encoded.push_str("sha256:");
|
||||
for byte in digest {
|
||||
use std::fmt::Write as _;
|
||||
write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail");
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
/// Build the deployment-independent canonical ServerApi OpenAPI document.
|
||||
pub fn canonical_openapi_document()
|
||||
-> Result<api_macros::openapi::OpenApiDocument, api_macros::openapi::OpenApiError> {
|
||||
let source_digest = canonical_openapi_source_digest();
|
||||
server_api_openapi(api_macros::openapi::OpenApiInfo {
|
||||
title: "Yoi Server API",
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
source_digest: &source_digest,
|
||||
})
|
||||
}
|
||||
|
||||
/// Public browser-authentication configuration.
|
||||
@@ -394,7 +501,7 @@ pub fn validate_repository_key(value: &str) -> Result<(), RepositoryKeyError> {
|
||||
///
|
||||
/// Local paths remain distinct from network Git transports so callers cannot
|
||||
/// accidentally treat an unmaterialized remote as a server-local filesystem path.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RepositorySourceKind {
|
||||
@@ -451,7 +558,7 @@ impl<'de> Deserialize<'de> for RepositorySourceKind {
|
||||
}
|
||||
|
||||
/// Stable Repository source identity stored by Workspace authority.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct RepositorySource {
|
||||
pub kind: RepositorySourceKind,
|
||||
@@ -464,7 +571,7 @@ pub struct RepositorySource {
|
||||
///
|
||||
/// The Server parses and canonicalizes `source`; callers cannot assert a
|
||||
/// transport classification or supply credential material through this DTO.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CreateWorkspaceRepositoryRequest {
|
||||
pub repository_key: String,
|
||||
@@ -473,14 +580,20 @@ pub struct CreateWorkspaceRepositoryRequest {
|
||||
pub default_ref: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
pub struct CreateWorkspaceRepositoryResponse {
|
||||
pub workspace_id: String,
|
||||
pub repository_key: String,
|
||||
pub replayed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
impl api_macros::HttpSuccess for CreateWorkspaceRepositoryResponse {
|
||||
fn status_code(&self) -> u16 {
|
||||
if self.replayed { 200 } else { 201 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RepositoryObservedStatus {
|
||||
@@ -1125,7 +1238,7 @@ pub enum WorkspaceProfileSourceProvenance {
|
||||
ProjectProfileSourceTree,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RepositoryDiagnostic {
|
||||
@@ -1134,7 +1247,7 @@ pub struct RepositoryDiagnostic {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct GitRemoteSummary {
|
||||
@@ -1142,7 +1255,7 @@ pub struct GitRemoteSummary {
|
||||
pub fetch_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct GitRepositorySummary {
|
||||
@@ -1153,7 +1266,7 @@ pub struct GitRepositorySummary {
|
||||
pub remotes: Vec<GitRemoteSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RepositorySummary {
|
||||
@@ -1162,6 +1275,7 @@ pub struct RepositorySummary {
|
||||
pub provider: String,
|
||||
pub source: RepositorySource,
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||
pub source_revision: u64,
|
||||
pub source_fingerprint: String,
|
||||
pub observed_status: RepositoryObservedStatus,
|
||||
@@ -1194,7 +1308,7 @@ pub struct GitCommitSummary {
|
||||
pub refs: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RepositoryListResponse {
|
||||
@@ -1204,7 +1318,7 @@ pub struct RepositoryListResponse {
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RepositoryDetailResponse {
|
||||
@@ -1282,7 +1396,7 @@ pub struct RepositoryLogResponse {
|
||||
pub const TICKET_RELATIONS_QUERY_PATH: &str = "/tickets/relations/search";
|
||||
pub const TICKET_ORCHESTRATION_PLANS_QUERY_PATH: &str = "/tickets/orchestration-plans/search";
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DiagnosticSeverity {
|
||||
@@ -1291,7 +1405,7 @@ pub enum DiagnosticSeverity {
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Diagnostic {
|
||||
@@ -4985,6 +5099,60 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod openapi_artifact_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn canonical_openapi_artifact_is_current() {
|
||||
let generated = canonical_openapi_document()
|
||||
.expect("canonical OpenAPI contract must be valid")
|
||||
.to_json()
|
||||
.expect("canonical OpenAPI contract must serialize");
|
||||
let checked_in = include_str!("../../../openapi/server-api.json");
|
||||
assert_eq!(
|
||||
generated, checked_in,
|
||||
"regenerate with `cargo run -p server-api --example export_openapi -- openapi/server-api.json`",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_operations_are_in_the_canonical_contract() {
|
||||
let document = canonical_openapi_document().expect("canonical OpenAPI contract must build");
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(&document.to_json().expect("document must serialize"))
|
||||
.expect("document must be JSON");
|
||||
|
||||
assert_eq!(value["openapi"], "3.1.0");
|
||||
assert!(
|
||||
value["paths"]
|
||||
["/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/session"]
|
||||
.is_null()
|
||||
);
|
||||
assert!(
|
||||
value["info"]["x-yoi-source-digest"]
|
||||
.as_str()
|
||||
.is_some_and(|digest| digest.starts_with("sha256:"))
|
||||
);
|
||||
assert_eq!(
|
||||
value["paths"]["/api/w/{workspace_id}/repositories"]["get"]["responses"]["200"]["content"]
|
||||
["application/json"]["schema"]["$ref"],
|
||||
"#/components/schemas/RepositoryListResponse"
|
||||
);
|
||||
assert!(
|
||||
value["paths"]["/api/w/{workspace_id}/repositories"]["post"]["responses"]["200"]
|
||||
.is_object()
|
||||
);
|
||||
assert!(
|
||||
value["paths"]["/api/w/{workspace_id}/repositories"]["post"]["responses"]["201"]
|
||||
.is_object()
|
||||
);
|
||||
assert!(value["paths"]["/api/w/{workspace_id}/repositories/{repository_key}"]
|
||||
["get"]["responses"]["404"]
|
||||
.is_object());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "typescript"))]
|
||||
mod typescript_tests {
|
||||
#[test]
|
||||
|
||||
@@ -3074,6 +3074,56 @@ impl server_api::ServerApi for WorkspaceApi {
|
||||
observation,
|
||||
})
|
||||
}
|
||||
|
||||
async fn repository_list(
|
||||
&self,
|
||||
workspace_id: String,
|
||||
) -> std::result::Result<server_api::RepositoryListResponse, server_api::RepositoryApiError>
|
||||
{
|
||||
scoped_list_repositories(
|
||||
State(self.clone()),
|
||||
AxumPath(ScopedWorkspacePath { workspace_id }),
|
||||
)
|
||||
.await
|
||||
.map(|Json(response)| response)
|
||||
.map_err(ApiError::into_repository_api_error)
|
||||
}
|
||||
|
||||
async fn repository_detail(
|
||||
&self,
|
||||
workspace_id: String,
|
||||
repository_key: String,
|
||||
) -> std::result::Result<server_api::RepositoryDetailResponse, server_api::RepositoryApiError>
|
||||
{
|
||||
scoped_repository_detail(
|
||||
State(self.clone()),
|
||||
AxumPath(ScopedRepositoryPath {
|
||||
workspace_id,
|
||||
repository_key,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.map(|Json(response)| response)
|
||||
.map_err(ApiError::into_repository_api_error)
|
||||
}
|
||||
|
||||
async fn repository_create(
|
||||
&self,
|
||||
actor: RequestActor,
|
||||
workspace_id: String,
|
||||
request: CreateWorkspaceRepositoryRequest,
|
||||
) -> std::result::Result<CreateWorkspaceRepositoryResponse, server_api::RepositoryApiError>
|
||||
{
|
||||
scoped_create_repository(
|
||||
State(self.clone()),
|
||||
AxumPath(ScopedWorkspacePath { workspace_id }),
|
||||
Extension(actor),
|
||||
Json(request),
|
||||
)
|
||||
.await
|
||||
.map(|(_status, Json(response))| response)
|
||||
.map_err(ApiError::into_repository_api_error)
|
||||
}
|
||||
}
|
||||
|
||||
fn build_inner_router(api: WorkspaceApi) -> Router {
|
||||
@@ -3399,15 +3449,7 @@ fn build_inner_router(api: WorkspaceApi) -> Router {
|
||||
delete(scoped_unlink_objective_ticket),
|
||||
)
|
||||
.route("/api/repositories", get(list_repositories))
|
||||
.route(
|
||||
"/api/w/{workspace_id}/repositories",
|
||||
get(scoped_list_repositories).post(scoped_create_repository),
|
||||
)
|
||||
.route("/api/repositories/{repository_key}", get(repository_detail))
|
||||
.route(
|
||||
"/api/w/{workspace_id}/repositories/{repository_key}",
|
||||
get(scoped_repository_detail),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/repositories/{repository_key}/ssh-connection-test",
|
||||
post(scoped_probe_repository_ssh_connection)
|
||||
@@ -18781,6 +18823,47 @@ impl ApiError {
|
||||
fn with_diagnostics(error: Error, diagnostics: Vec<RuntimeDiagnostic>) -> Self {
|
||||
Self { error, diagnostics }
|
||||
}
|
||||
|
||||
fn into_repository_api_error(self) -> server_api::RepositoryApiError {
|
||||
let status = match &self.error {
|
||||
Error::WorkspacePermissionDenied(_) | Error::BrowserReopenConfirmationRequired => {
|
||||
StatusCode::FORBIDDEN
|
||||
}
|
||||
Error::RepositoryConflict(_) => StatusCode::CONFLICT,
|
||||
Error::InvalidInput(_)
|
||||
| Error::WorkerSourceIdentity(_)
|
||||
| Error::InvalidRuntimeIdentifier { .. }
|
||||
| Error::ReservedWorkerName(_) => StatusCode::BAD_REQUEST,
|
||||
Error::RuntimeOperationFailed { code, .. } if code == "repository_not_configured" => {
|
||||
StatusCode::NOT_FOUND
|
||||
}
|
||||
Error::RuntimeOperationFailed { code, .. }
|
||||
if code == "repository_provider_unsupported" =>
|
||||
{
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
Error::InvalidRecordId(_)
|
||||
| Error::MissingFrontmatter(_)
|
||||
| Error::UnknownRepository(_)
|
||||
| Error::UnknownRuntime(_)
|
||||
| Error::UnknownWorker { .. }
|
||||
| Error::RuntimeBindingNotFound { .. }
|
||||
| Error::UnknownHost(_)
|
||||
| Error::WorkspaceIdMismatch => StatusCode::NOT_FOUND,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
let message = if status == StatusCode::INTERNAL_SERVER_ERROR {
|
||||
"internal server error".to_owned()
|
||||
} else {
|
||||
self.error.to_string()
|
||||
};
|
||||
server_api::RepositoryApiError::new(
|
||||
status.as_u16(),
|
||||
status.canonical_reason().unwrap_or("error"),
|
||||
message,
|
||||
working_directory_diagnostics(self.diagnostics),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
@@ -22846,6 +22929,45 @@ mod tests {
|
||||
.unwrap();
|
||||
assert!(String::from_utf8_lossy(&listed_body).contains("documentation"));
|
||||
|
||||
let repository_detail_uri = format!("{repositories_uri}/documentation");
|
||||
let detailed = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(repository_detail_uri)
|
||||
.header(
|
||||
axum::http::header::COOKIE,
|
||||
"yoi_workspace_session=browser-session-auth",
|
||||
)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(detailed.status(), StatusCode::OK);
|
||||
let missing = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("{repositories_uri}/missing"))
|
||||
.header(
|
||||
axum::http::header::COOKIE,
|
||||
"yoi_workspace_session=browser-session-auth",
|
||||
)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let missing_status = missing.status();
|
||||
let missing_body = to_bytes(missing.into_body(), usize::MAX).await.unwrap();
|
||||
assert_eq!(
|
||||
missing_status,
|
||||
StatusCode::NOT_FOUND,
|
||||
"{}",
|
||||
String::from_utf8_lossy(&missing_body)
|
||||
);
|
||||
|
||||
let identity_uri = format!(
|
||||
"/api/w/{}/settings/signing-identity",
|
||||
workspace.workspace.workspace_id
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
{
|
||||
"components": {
|
||||
"schemas": {
|
||||
"CreateWorkspaceRepositoryRequest": {
|
||||
"additionalProperties": false,
|
||||
"description": "Browser/user intent for registering one Repository in a Workspace.\n\nThe Server parses and canonicalizes `source`; callers cannot assert a\ntransport classification or supply credential material through this DTO.",
|
||||
"properties": {
|
||||
"default_ref": {
|
||||
"default": null,
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"repository_key": {
|
||||
"type": "string"
|
||||
},
|
||||
"source": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"repository_key",
|
||||
"source"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"CreateWorkspaceRepositoryResponse": {
|
||||
"properties": {
|
||||
"replayed": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"repository_key": {
|
||||
"type": "string"
|
||||
},
|
||||
"workspace_id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"workspace_id",
|
||||
"repository_key",
|
||||
"replayed"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"Diagnostic": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"severity": {
|
||||
"$ref": "#/components/schemas/DiagnosticSeverity"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"code",
|
||||
"severity",
|
||||
"message"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"DiagnosticSeverity": {
|
||||
"enum": [
|
||||
"info",
|
||||
"warning",
|
||||
"error"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"GitRemoteSummary": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"fetch_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"fetch_url"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"GitRepositorySummary": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"branch": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"dirty": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"head": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"remotes": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/GitRemoteSummary"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"dirty",
|
||||
"remotes"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RepositoryApiError": {
|
||||
"description": "Error body shared by the existing repository routes and their generated adapters.",
|
||||
"properties": {
|
||||
"diagnostics": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Diagnostic"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"error",
|
||||
"message"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RepositoryDetailResponse": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"item": {
|
||||
"$ref": "#/components/schemas/RepositorySummary"
|
||||
},
|
||||
"source": {
|
||||
"type": "string"
|
||||
},
|
||||
"workspace_id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"workspace_id",
|
||||
"item",
|
||||
"source"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RepositoryDiagnostic": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"severity": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"severity",
|
||||
"code",
|
||||
"message"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RepositoryListResponse": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"diagnostics": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Diagnostic"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"items": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/RepositorySummary"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"source": {
|
||||
"type": "string"
|
||||
},
|
||||
"workspace_id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"workspace_id",
|
||||
"items",
|
||||
"source",
|
||||
"diagnostics"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RepositoryObservedStatus": {
|
||||
"enum": [
|
||||
"unverified",
|
||||
"ready",
|
||||
"invalid"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"RepositorySource": {
|
||||
"description": "Stable Repository source identity stored by Workspace authority.",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"$ref": "#/components/schemas/RepositorySourceKind"
|
||||
},
|
||||
"uri": {
|
||||
"description": "Canonical source representation. This is an absolute local path for\n`local_path`, and a normalized URI/remote specification otherwise.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"uri"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RepositorySourceKind": {
|
||||
"description": "Provider-neutral classification of an authoritative Repository source.\n\nLocal paths remain distinct from network Git transports so callers cannot\naccidentally treat an unmaterialized remote as a server-local filesystem path.",
|
||||
"oneOf": [
|
||||
{
|
||||
"enum": [
|
||||
"local_path",
|
||||
"file",
|
||||
"ssh",
|
||||
"https"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"const": "invalid",
|
||||
"description": "A legacy value that could not be classified during migration. It remains\ninspectable but every provider operation must fail closed. Historical\n`http` wire values decode into this non-executable classification.",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"RepositorySummary": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"default_selector": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"diagnostics": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/RepositoryDiagnostic"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"git": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/GitRepositorySummary"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"kind": {
|
||||
"type": "string"
|
||||
},
|
||||
"observed_at": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"observed_status": {
|
||||
"$ref": "#/components/schemas/RepositoryObservedStatus"
|
||||
},
|
||||
"provider": {
|
||||
"type": "string"
|
||||
},
|
||||
"record_authority": {
|
||||
"type": "string"
|
||||
},
|
||||
"repository_key": {
|
||||
"type": "string"
|
||||
},
|
||||
"source": {
|
||||
"$ref": "#/components/schemas/RepositorySource"
|
||||
},
|
||||
"source_fingerprint": {
|
||||
"type": "string"
|
||||
},
|
||||
"source_revision": {
|
||||
"format": "uint64",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"repository_key",
|
||||
"kind",
|
||||
"provider",
|
||||
"source",
|
||||
"source_revision",
|
||||
"source_fingerprint",
|
||||
"observed_status",
|
||||
"record_authority"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"string": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
"title": "Yoi Server API",
|
||||
"version": "0.1.0",
|
||||
"x-yoi-source-digest": "sha256:1a30fd87bc5d6e1a03acfb86c86630ad8d458e5c0db8b3d146459d5f2c376db6"
|
||||
},
|
||||
"openapi": "3.1.0",
|
||||
"paths": {
|
||||
"/api/w/{workspace_id}/repositories": {
|
||||
"get": {
|
||||
"operationId": "repository_list",
|
||||
"parameters": [
|
||||
{
|
||||
"explode": false,
|
||||
"in": "path",
|
||||
"name": "workspace_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/string"
|
||||
},
|
||||
"style": "simple"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryListResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful response"
|
||||
},
|
||||
"400": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"operationId": "repository_create",
|
||||
"parameters": [
|
||||
{
|
||||
"explode": false,
|
||||
"in": "path",
|
||||
"name": "workspace_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/string"
|
||||
},
|
||||
"style": "simple"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CreateWorkspaceRepositoryRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CreateWorkspaceRepositoryResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Alternate successful response"
|
||||
},
|
||||
"201": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CreateWorkspaceRepositoryResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful response"
|
||||
},
|
||||
"400": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/w/{workspace_id}/repositories/{repository_key}": {
|
||||
"get": {
|
||||
"operationId": "repository_detail",
|
||||
"parameters": [
|
||||
{
|
||||
"explode": false,
|
||||
"in": "path",
|
||||
"name": "workspace_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/string"
|
||||
},
|
||||
"style": "simple"
|
||||
},
|
||||
{
|
||||
"explode": false,
|
||||
"in": "path",
|
||||
"name": "repository_key",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/string"
|
||||
},
|
||||
"style": "simple"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryDetailResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful response"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user