From 8d3912229443400d730ffc21e43a113938926fb8 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 6 Nov 2026 10:35:14 +0900 Subject: [PATCH] fix(api): enable typed creation error hooks on generated Backend clients --- crates/server-api/src/lib.rs | 9 +- .../tests/worker_create_contract.rs | 96 +++++++++++++++++++ openapi/server-api.json | 32 ++++++- .../src/lib/generated/companion-api.ts | 2 +- .../src/lib/generated/repository-api.ts | 2 +- .../src/lib/generated/runtime-api.ts | 2 +- web/workspace/src/lib/generated/ticket-api.ts | 2 +- .../src/lib/generated/workdir-api.ts | 2 +- .../src/lib/generated/worker-launch-api.ts | 2 +- 9 files changed, 139 insertions(+), 10 deletions(-) create mode 100644 crates/server-api/tests/worker_create_contract.rs diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 1f91dff42..bffc349b8 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -1909,8 +1909,9 @@ pub trait ServerApi { "/api/workers", status = 200, error_status = 400, - additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503, 504], additional_error_content_types = ["application/problem+json"], + client_error_hook = true, bearer_auth = true, browser_auth = true )] @@ -1976,7 +1977,8 @@ pub trait ServerApi { "/api/w/{workspace_id}/worker-control/workers", status = 200, error_status = 400, - additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503, 504], + client_error_hook = true, openapi = false, )] async fn worker_control_spawn( @@ -2101,8 +2103,9 @@ pub trait ServerApi { "/api/w/{workspace_id}/workers", status = 200, error_status = 400, - additional_error_statuses = [401, 403, 404, 409, 500, 502, 503], + additional_error_statuses = [401, 403, 404, 409, 500, 502, 503, 504], additional_error_content_types = ["application/problem+json"], + client_error_hook = true, bearer_auth = true, browser_auth = true )] diff --git a/crates/server-api/tests/worker_create_contract.rs b/crates/server-api/tests/worker_create_contract.rs new file mode 100644 index 000000000..7f3503fd4 --- /dev/null +++ b/crates/server-api/tests/worker_create_contract.rs @@ -0,0 +1,96 @@ +//! Generated Backend client → isolated loopback HTTP response → typed result. +//! The server response producer is substituted; authorization and persistence are +//! exercised by workspace-server's connected tests, not by this codec connection. +use runtime_api::startup::*; +use server_api::{CreateWorkspaceWorkerRequest, ServerApiClient, client_support, server_support}; +use server_support::framework::{Body, Response, Router, post}; + +struct ServerTask(tokio::task::JoinHandle<()>); +impl Drop for ServerTask { + fn drop(&mut self) { + self.0.abort(); + } +} + +fn request() -> CreateWorkspaceWorkerRequest { + serde_json::from_value(serde_json::json!({ + "runtime_id": "runtime", "display_name": "Worker", "feature_connections": {} + })) + .unwrap() +} + +#[tokio::test] +async fn every_creation_client_decodes_actual_problem_status_and_operation_facts() { + let timeout = WorkerCreateFailure::from_cause( + StartupCause::Boundary(StartupBoundaryCause { + reason: StartupBoundaryReason::Timeout, + }), + StartupOperationFacts { + acceptance: CreationAcceptance::Unknown, + execution: ExecutionOutcome::Unknown, + finalize: None, + backend_compensation: None, + correlation: None, + }, + ) + .unwrap() + .problem( + StartupBoundary::Browser, + &SessionFailureVisibility::default(), + "", + ) + .unwrap(); + for (status, body) in [ + (409, include_bytes!("../../runtime-api/tests/fixtures/startup-browser-session-record-denied.problem.json").to_vec()), + (502, include_bytes!("../../runtime-api/tests/fixtures/startup-browser-registration-response-lost.problem.json").to_vec()), + (504, timeout.into_body()), + ] { + let DecodeStartupProblem::Known(expected) = decode_startup_problem(&body, status, PROBLEM_CONTENT_TYPE, StartupBoundary::Browser) else { + panic!("producer fixture must have a known typed cause"); + }; + let router = ["/api/workers", "/api/w/workspace/workers", "/api/w/workspace/worker-control/workers"] + .into_iter().fold(Router::new(), |router, path| { + let body = body.clone(); + router.route(path, post(move || { + let body = body.clone(); + async move { Response::builder().status(status).header("content-type", PROBLEM_CONTENT_TYPE).body(Body::from(body)).unwrap() } + })) + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let _server = ServerTask(tokio::spawn(async move { + server_support::framework::serve(listener, router).await.unwrap(); + })); + let client = ServerApiClient::builder(format!("http://{address}")) + .unwrap().request_timeout(std::time::Duration::from_secs(3)).build().unwrap(); + for result in [ + client.workspace_worker_create("workspace".into(), request()).await, + client.workspace_worker_create_alias(request()).await, + client.worker_control_spawn("workspace".into(), request()).await, + ] { + let Err(client_support::ClientError::Public { status: actual_status, error }) = result else { + panic!("generated route must invoke the Problem decoder, not legacy Deserialize"); + }; + assert_eq!(actual_status.as_u16(), status); + let actual = error.failure().expect("retain typed non-HTTP failure"); + assert_eq!(actual.startup().primary, expected.startup().primary); + assert_eq!(actual.public_facts(), expected.public_facts()); + } + } +} + +#[test] +fn creation_openapi_declares_gateway_timeout_with_both_error_representations() { + let document: serde_json::Value = serde_json::from_str( + &server_api::canonical_openapi_document() + .unwrap() + .to_json() + .unwrap(), + ) + .unwrap(); + for path in ["/api/workers", "/api/w/{workspace_id}/workers"] { + let content = &document["paths"][path]["post"]["responses"]["504"]["content"]; + assert!(content.get("application/problem+json").is_some()); + assert!(content.get("application/json").is_some()); + } +} diff --git a/openapi/server-api.json b/openapi/server-api.json index 61c6af39e..894587dcf 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -24199,7 +24199,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:afde3903f29b9087390b69f9761a935cd5004c4dab17ce684f4149c4abae89c4" + "x-yoi-source-digest": "sha256:2dad54b31293643566259ea555e11c46effce33aab9f30c8a861a46303024bad" }, "openapi": "3.1.0", "paths": { @@ -49679,6 +49679,21 @@ } }, "description": "Error response" + }, + "504": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/StartupProblemDetails" + } + } + }, + "description": "Error response" } }, "security": [ @@ -50936,6 +50951,21 @@ } }, "description": "Error response" + }, + "504": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/StartupProblemDetails" + } + } + }, + "description": "Error response" } }, "security": [ diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index e250d0996..5864393a5 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-companion-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:afde3903f29b9087390b69f9761a935cd5004c4dab17ce684f4149c4abae89c4) +// Input contract: 0.1.0 (sha256:2dad54b31293643566259ea555e11c46effce33aab9f30c8a861a46303024bad) // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/companion-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index ae5933862..877fab10e 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:afde3903f29b9087390b69f9761a935cd5004c4dab17ce684f4149c4abae89c4) +// Input contract: 0.1.0 (sha256:2dad54b31293643566259ea555e11c46effce33aab9f30c8a861a46303024bad) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index da0bd6e65..79a2a31cf 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-runtime-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:afde3903f29b9087390b69f9761a935cd5004c4dab17ce684f4149c4abae89c4) +// Input contract: 0.1.0 (sha256:2dad54b31293643566259ea555e11c46effce33aab9f30c8a861a46303024bad) // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/runtime-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index 7c13f5cfa..232e27bbf 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:afde3903f29b9087390b69f9761a935cd5004c4dab17ce684f4149c4abae89c4) +// Input contract: 0.1.0 (sha256:2dad54b31293643566259ea555e11c46effce33aab9f30c8a861a46303024bad) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check diff --git a/web/workspace/src/lib/generated/workdir-api.ts b/web/workspace/src/lib/generated/workdir-api.ts index 5cc28e79a..cc5043dd9 100644 --- a/web/workspace/src/lib/generated/workdir-api.ts +++ b/web/workspace/src/lib/generated/workdir-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-workdir-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:afde3903f29b9087390b69f9761a935cd5004c4dab17ce684f4149c4abae89c4) +// Input contract: 0.1.0 (sha256:2dad54b31293643566259ea555e11c46effce33aab9f30c8a861a46303024bad) // Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/workdir-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index f5364af97..967af2325 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-worker-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:afde3903f29b9087390b69f9761a935cd5004c4dab17ce684f4149c4abae89c4) +// Input contract: 0.1.0 (sha256:2dad54b31293643566259ea555e11c46effce33aab9f30c8a861a46303024bad) // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/worker-launch-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check