Migrate repository routes to ServerApi

This commit is contained in:
2026-09-18 17:17:02 +09:00
parent bd07f3a66f
commit e12b555f43
11 changed files with 1020 additions and 49 deletions
+3 -1
View File
@@ -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
View File
@@ -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]