From 9c107eaf746254c15b439eac634d80ecb5c4c410 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 23 Sep 2026 21:07:20 +0900 Subject: [PATCH] feat(api): declare typed WebSocket operations --- crates/api-macros-impl/src/lib.rs | 389 ++++++++++++++++-- crates/api-macros/src/axum.rs | 24 +- crates/api-macros/src/lib.rs | 59 ++- crates/api-macros/tests/expansion.rs | 47 ++- crates/api-macros/tests/http_adapters.rs | 54 +++ crates/api-macros/tests/openapi.rs | 17 + .../ui/duplicate_websocket_path_parameter.rs | 17 + .../duplicate_websocket_path_parameter.stderr | 5 + .../tests/ui/duplicate_websocket_route.rs | 21 + .../tests/ui/duplicate_websocket_route.stderr | 5 + .../ui/invalid_websocket_alternate_status.rs | 18 + .../invalid_websocket_alternate_status.stderr | 5 + .../tests/ui/invalid_websocket_binary_body.rs | 19 + .../ui/invalid_websocket_binary_body.stderr | 5 + .../tests/ui/invalid_websocket_body.rs | 19 + .../tests/ui/invalid_websocket_body.stderr | 5 + .../tests/ui/invalid_websocket_frame.rs | 17 + .../tests/ui/invalid_websocket_frame.stderr | 5 + .../tests/ui/invalid_websocket_method.rs | 17 + .../tests/ui/invalid_websocket_method.stderr | 5 + .../tests/ui/invalid_websocket_response.rs | 18 + .../ui/invalid_websocket_response.stderr | 5 + .../ui/invalid_websocket_response_body.rs | 19 + .../ui/invalid_websocket_response_body.stderr | 5 + .../tests/ui/missing_websocket_frame.rs | 16 + .../tests/ui/missing_websocket_frame.stderr | 5 + .../tests/ui/websocket_path_mismatch.rs | 17 + .../tests/ui/websocket_path_mismatch.stderr | 5 + .../ui/websocket_unused_path_parameter.rs | 17 + .../ui/websocket_unused_path_parameter.stderr | 5 + crates/server-api/src/lib.rs | 282 ++++++++++++- crates/workspace-server/src/server.rs | 50 ++- openapi/server-api.json | 2 +- .../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 +- 39 files changed, 1144 insertions(+), 67 deletions(-) create mode 100644 crates/api-macros/tests/ui/duplicate_websocket_path_parameter.rs create mode 100644 crates/api-macros/tests/ui/duplicate_websocket_path_parameter.stderr create mode 100644 crates/api-macros/tests/ui/duplicate_websocket_route.rs create mode 100644 crates/api-macros/tests/ui/duplicate_websocket_route.stderr create mode 100644 crates/api-macros/tests/ui/invalid_websocket_alternate_status.rs create mode 100644 crates/api-macros/tests/ui/invalid_websocket_alternate_status.stderr create mode 100644 crates/api-macros/tests/ui/invalid_websocket_binary_body.rs create mode 100644 crates/api-macros/tests/ui/invalid_websocket_binary_body.stderr create mode 100644 crates/api-macros/tests/ui/invalid_websocket_body.rs create mode 100644 crates/api-macros/tests/ui/invalid_websocket_body.stderr create mode 100644 crates/api-macros/tests/ui/invalid_websocket_frame.rs create mode 100644 crates/api-macros/tests/ui/invalid_websocket_frame.stderr create mode 100644 crates/api-macros/tests/ui/invalid_websocket_method.rs create mode 100644 crates/api-macros/tests/ui/invalid_websocket_method.stderr create mode 100644 crates/api-macros/tests/ui/invalid_websocket_response.rs create mode 100644 crates/api-macros/tests/ui/invalid_websocket_response.stderr create mode 100644 crates/api-macros/tests/ui/invalid_websocket_response_body.rs create mode 100644 crates/api-macros/tests/ui/invalid_websocket_response_body.stderr create mode 100644 crates/api-macros/tests/ui/missing_websocket_frame.rs create mode 100644 crates/api-macros/tests/ui/missing_websocket_frame.stderr create mode 100644 crates/api-macros/tests/ui/websocket_path_mismatch.rs create mode 100644 crates/api-macros/tests/ui/websocket_path_mismatch.stderr create mode 100644 crates/api-macros/tests/ui/websocket_unused_path_parameter.rs create mode 100644 crates/api-macros/tests/ui/websocket_unused_path_parameter.stderr diff --git a/crates/api-macros-impl/src/lib.rs b/crates/api-macros-impl/src/lib.rs index b6f302e42..8014ff556 100644 --- a/crates/api-macros-impl/src/lib.rs +++ b/crates/api-macros-impl/src/lib.rs @@ -26,6 +26,12 @@ pub fn api(attr: TokenStream, item: TokenStream) -> TokenStream { .into() } +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +enum Transport { + Http, + WebSocket, +} + #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] enum Method { Get, @@ -114,6 +120,98 @@ struct RouteArgs { openapi: Option, } +struct WebSocketArgs { + path: LitStr, + operation_id: Option, + method: Option, + client_to_server: Option, + server_to_client: Option, + path_parameters: Vec<(Ident, Type)>, +} + +impl Parse for WebSocketArgs { + fn parse(input: ParseStream<'_>) -> syn::Result { + let path = input.parse()?; + let mut result = Self { + path, + operation_id: None, + method: None, + client_to_server: None, + server_to_client: None, + path_parameters: Vec::new(), + }; + let mut saw_path_parameters = false; + while !input.is_empty() { + input.parse::()?; + if input.is_empty() { + break; + } + let key: Ident = input.parse()?; + input.parse::()?; + match key.to_string().as_str() { + "operation_id" => set_once( + &mut result.operation_id, + input.parse::()?, + &key, + "operation_id", + )?, + "method" => set_once(&mut result.method, input.parse::()?, &key, "method")?, + "client_to_server" => set_once( + &mut result.client_to_server, + input.parse::()?, + &key, + "client_to_server", + )?, + "server_to_client" => set_once( + &mut result.server_to_client, + input.parse::()?, + &key, + "server_to_client", + )?, + "path_parameters" => { + if saw_path_parameters { + return Err(syn::Error::new( + key.span(), + "duplicate `path_parameters` WebSocket option", + )); + } + saw_path_parameters = true; + let content; + syn::bracketed!(content in input); + while !content.is_empty() { + let name = content.parse::()?; + content.parse::()?; + let ty = content.parse::()?; + result.path_parameters.push((name, ty)); + if !content.is_empty() { + content.parse::()?; + } + } + } + "body" | "binary" => { + return Err(syn::Error::new( + key.span(), + "WebSocket operations cannot declare JSON or binary request bodies", + )); + } + "status" | "alternate_status" | "responses" | "response" => { + return Err(syn::Error::new( + key.span(), + "WebSocket operations cannot declare unary success responses or statuses", + )); + } + _ => { + return Err(syn::Error::new( + key.span(), + "unsupported WebSocket option; expected operation_id, method, client_to_server, server_to_client, or path_parameters", + )); + } + } + } + Ok(result) + } +} + struct DeclaredResponseHeader { name: LitStr, field_ident: Ident, @@ -381,9 +479,12 @@ struct Operation { method_ident: Ident, marker_ident: Ident, operation_id: String, + transport: Transport, method: Method, path: String, parameters: Vec, + client_to_server_frame: Option, + server_to_client_frame: Option, request_body: Option, response_body: Option, success_responses: Vec, @@ -434,24 +535,36 @@ fn normalize_api( let mut routes = BTreeMap::<(Method, String), Span>::new(); let mut marker_names = BTreeMap::::new(); - for trait_item in &mut item.items { - let TraitItem::Fn(method) = trait_item else { - return Err(syn::Error::new_spanned( - trait_item, - "an #[api] trait may contain methods only", - )); + let mut retained_items = Vec::new(); + for mut trait_item in std::mem::take(&mut item.items) { + let operation = match &mut trait_item { + TraitItem::Fn(method) => { + let operation = normalize_operation(method)?; + retained_items.push(trait_item); + operation + } + TraitItem::Type(declaration) if has_websocket_attribute(&declaration.attrs) => { + normalize_websocket_operation(declaration)? + } + _ => { + return Err(syn::Error::new_spanned( + trait_item, + "an #[api] trait may contain HTTP methods and #[websocket] operation declarations only", + )); + } }; - let operation = normalize_operation(method)?; + let source_ident = operation.method_ident.clone(); + let source_name = ident_text(&source_ident); + let source_span = source_ident.span(); let marker_name = operation.marker_ident.to_string(); - if let Some((first_method, first_span)) = marker_names.insert( - marker_name.clone(), - (ident_text(&method.sig.ident), method.sig.ident.span()), - ) { + if let Some((first_operation, first_span)) = + marker_names.insert(marker_name.clone(), (source_name, source_span)) + { let mut error = syn::Error::new( - method.sig.ident.span(), + source_span, format!( - "generated operation marker `{marker_name}` collides with method `{first_method}`" + "generated operation marker `{marker_name}` collides with method `{first_operation}`" ), ); error.combine(syn::Error::new( @@ -462,23 +575,20 @@ fn normalize_api( } if operation_ids - .insert(operation.operation_id.clone(), method.sig.ident.span()) + .insert(operation.operation_id.clone(), source_span) .is_some() { return Err(syn::Error::new( - method.sig.ident.span(), + source_span, format!("duplicate operation ID `{}`", operation.operation_id), )); } if routes - .insert( - (operation.method, operation.path.clone()), - method.sig.ident.span(), - ) + .insert((operation.method, operation.path.clone()), source_span) .is_some() { return Err(syn::Error::new( - method.sig.ident.span(), + source_span, format!( "duplicate route for {} `{}`", method_name(operation.method), @@ -488,6 +598,7 @@ fn normalize_api( } operations.push(operation); } + item.items = retained_items; if operations.is_empty() { return Err(syn::Error::new_spanned( @@ -528,6 +639,156 @@ fn make_service_futures_send(item: &mut ItemTrait) -> syn::Result<()> { Ok(()) } +fn has_websocket_attribute(attrs: &[Attribute]) -> bool { + attrs.iter().any(|attr| attr.path().is_ident("websocket")) +} + +fn normalize_websocket_operation(declaration: &mut syn::TraitItemType) -> syn::Result { + if !declaration.generics.params.is_empty() + || declaration.generics.where_clause.is_some() + || !declaration.bounds.is_empty() + || declaration.default.is_some() + { + return Err(syn::Error::new_spanned( + declaration, + "a #[websocket] declaration cannot have generics, bounds, or a default type", + )); + } + + let mut route = None; + for attr in &declaration.attrs { + if attr.path().is_ident("websocket") { + if route.is_some() { + return Err(syn::Error::new_spanned( + attr, + "a WebSocket operation must have exactly one #[websocket] attribute", + )); + } + route = Some(attr.parse_args::()?); + } else if !attr.path().is_ident("doc") { + return Err(syn::Error::new_spanned( + attr, + "a #[websocket] declaration supports only documentation attributes", + )); + } + } + let route = route.expect("caller checked for a WebSocket attribute"); + let method = route.method.ok_or_else(|| { + syn::Error::new_spanned( + &route.path, + "WebSocket operations require explicit `method = GET`", + ) + })?; + if method != "GET" { + return Err(syn::Error::new_spanned( + method, + "WebSocket operations require `method = GET`", + )); + } + let client_to_server_frame = route.client_to_server.ok_or_else(|| { + syn::Error::new_spanned( + &route.path, + "WebSocket operations require `client_to_server = FrameType`", + ) + })?; + let server_to_client_frame = route.server_to_client.ok_or_else(|| { + syn::Error::new_spanned( + &route.path, + "WebSocket operations require `server_to_client = FrameType`", + ) + })?; + validate_frame_type(&client_to_server_frame)?; + validate_frame_type(&server_to_client_frame)?; + + let path = route.path.value(); + let placeholders = parse_path_template(&route.path)?; + let mut path_parameters = BTreeSet::new(); + let mut parameters = Vec::new(); + for (ident, ty) in route.path_parameters { + let rust_name = ident_text(&ident); + if !path_parameters.insert(rust_name.clone()) { + return Err(syn::Error::new( + ident.span(), + format!("duplicate path parameter `{rust_name}`"), + )); + } + parameters.push(Parameter { + rust_ident: ident, + rust_name: rust_name.clone(), + wire_name: rust_name, + location: Location::Path, + ty, + }); + } + let missing: Vec<_> = placeholders.difference(&path_parameters).cloned().collect(); + if !missing.is_empty() { + return Err(syn::Error::new( + route.path.span(), + format!( + "path placeholder(s) have no matching path parameter: {}", + missing.join(", ") + ), + )); + } + let extra: Vec<_> = path_parameters.difference(&placeholders).cloned().collect(); + if !extra.is_empty() { + return Err(syn::Error::new( + route.path.span(), + format!( + "path parameter(s) have no matching placeholder: {}", + extra.join(", ") + ), + )); + } + + let operation_id = route + .operation_id + .as_ref() + .map(LitStr::value) + .unwrap_or_else(|| to_snake_case(&declaration.ident)); + validate_operation_id( + &operation_id, + route.operation_id.as_ref().unwrap_or(&route.path), + )?; + + Ok(Operation { + method_ident: declaration.ident.clone(), + marker_ident: declaration.ident.clone(), + operation_id, + transport: Transport::WebSocket, + method: Method::Get, + path, + parameters, + client_to_server_frame: Some(client_to_server_frame), + server_to_client_frame: Some(server_to_client_frame), + request_body: None, + response_body: None, + success_responses: Vec::new(), + declared_responses: false, + error_body: None, + fallible: false, + response_status: 101, + alternate_status: None, + error_status: None, + additional_error_statuses: Vec::new(), + bearer_auth: false, + browser_auth: false, + normalize_body_errors: false, + openapi_skip: true, + }) +} + +fn validate_frame_type(ty: &Type) -> syn::Result<()> { + if matches!(ty, Type::Path(path) if path.qself.is_none()) { + Ok(()) + } else { + Err(syn::Error::new_spanned( + ty, + "WebSocket frame authorities must use direct named Rust types", + )) + } +} + fn normalize_operation(method: &mut TraitItemFn) -> syn::Result { validate_signature(method)?; @@ -912,9 +1173,12 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result { marker_ident: operation_marker_ident(&method.sig.ident), method_ident: method.sig.ident.clone(), operation_id, + transport: Transport::Http, method: http_method, path, parameters, + client_to_server_frame: None, + server_to_client_frame: None, request_body: body_type, response_body, success_responses, @@ -1379,7 +1643,11 @@ fn reqwest_adapter_tokens( to_snake_case(trait_ident), span = trait_ident.span() ); - let methods = api.operations.iter().map(|operation| { + let methods = api + .operations + .iter() + .filter(|operation| matches!(operation.transport, Transport::Http)) + .map(|operation| { let method_ident = &operation.method_ident; let arguments = operation.parameters.iter().filter_map(|parameter| { if matches!(parameter.location, Location::Extension) { @@ -1716,7 +1984,11 @@ fn axum_adapter_tokens( to_snake_case(trait_ident), span = trait_ident.span() ); - let route_functions: Vec<_> = api.operations.iter().map(|operation| { + let route_functions: Vec<_> = api + .operations + .iter() + .filter(|operation| matches!(operation.transport, Transport::Http)) + .map(|operation| { let method_ident = &operation.method_ident; let handler_ident = format_ident!("{}_handler", ident_text(method_ident)); let route_path = &operation.path; @@ -1959,10 +2231,14 @@ fn axum_adapter_tokens( } } }).collect(); - let operation_merges = api.operations.iter().map(|operation| { - let method_ident = &operation.method_ident; - quote!(__router = __router.merge(#module_ident::#method_ident(__service.clone()));) - }); + let operation_merges = api + .operations + .iter() + .filter(|operation| matches!(operation.transport, Transport::Http)) + .map(|operation| { + let method_ident = &operation.method_ident; + quote!(__router = __router.merge(#module_ident::#method_ident(__service.clone()));) + }); quote! { #[doc = concat!("Per-operation Axum routers for [`", stringify!(#trait_ident), "`].")] @@ -2005,7 +2281,9 @@ fn openapi_adapter_tokens( let operations = api .operations .iter() - .filter(|operation| !operation.openapi_skip) + .filter(|operation| { + matches!(operation.transport, Transport::Http) && !operation.openapi_skip + }) .map(|operation| { let method = method_name(operation.method); let path = &operation.path; @@ -2188,9 +2466,9 @@ fn expand_api(api: ApiDefinition) -> syn::Result { .iter() .map(|operation| { let marker_ident = &operation.marker_ident; - let method_ident = &operation.method_ident; + let declaration_ident = &operation.method_ident; quote! { - #[doc = concat!("Typed operation marker for Rust method `", stringify!(#method_ident), "`.")] + #[doc = concat!("Typed operation marker for contract declaration `", stringify!(#declaration_ident), "`.")] #[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)] pub struct #marker_ident; } @@ -2199,6 +2477,7 @@ fn expand_api(api: ApiDefinition) -> syn::Result { let operation_impls: Vec<_> = api .operations .iter() + .filter(|operation| matches!(operation.transport, Transport::Http)) .map(|operation| { let marker_ident = &operation.marker_ident; let metadata = metadata_tokens(operation, &api_crate); @@ -2239,6 +2518,35 @@ fn expand_api(api: ApiDefinition) -> syn::Result { } }) .collect(); + let websocket_operation_impls: Vec<_> = api + .operations + .iter() + .filter(|operation| matches!(operation.transport, Transport::WebSocket)) + .map(|operation| { + let marker_ident = &operation.marker_ident; + let metadata = metadata_tokens(operation, &api_crate); + let path_types = ordered_path_parameters(operation) + .into_iter() + .map(|parameter| ¶meter.ty); + let client_to_server = operation + .client_to_server_frame + .as_ref() + .expect("WebSocket operation has a client frame"); + let server_to_client = operation + .server_to_client_frame + .as_ref() + .expect("WebSocket operation has a server frame"); + quote! { + impl #api_crate::WebSocketOperation for #module_ident::#marker_ident { + type PathParameters = (#(#path_types,)*); + type ClientToServerFrame = #client_to_server; + type ServerToClientFrame = #server_to_client; + + const METADATA: #api_crate::OperationMetadata = #metadata; + } + } + }) + .collect(); let inventory: Vec<_> = api .operations .iter() @@ -2271,6 +2579,7 @@ fn expand_api(api: ApiDefinition) -> syn::Result { } #(#operation_impls)* + #(#websocket_operation_impls)* #reqwest_adapter #axum_adapter @@ -2285,6 +2594,25 @@ fn metadata_tokens( let operation_id = &operation.operation_id; let method = operation.method.tokens(api_crate); let path = &operation.path; + let transport = match operation.transport { + Transport::Http => quote!(#api_crate::TransportMetadata::Http), + Transport::WebSocket => { + let client_to_server = operation + .client_to_server_frame + .as_ref() + .expect("WebSocket operation has a client frame"); + let server_to_client = operation + .server_to_client_frame + .as_ref() + .expect("WebSocket operation has a server frame"); + quote! { + #api_crate::TransportMetadata::WebSocket { + client_to_server_frame: stringify!(#client_to_server), + server_to_client_frame: stringify!(#server_to_client), + } + } + } + }; let request_kind = match operation.request_body.as_ref().map(|body| body.wire_kind) { Some(RequestWireKind::Json) => quote!(#api_crate::WireKind::Json), Some(RequestWireKind::Binary) => quote!(#api_crate::WireKind::Binary), @@ -2333,11 +2661,13 @@ fn metadata_tokens( return None; } let rust_name = ¶meter.rust_name; + let ty = ¶meter.ty; let wire_name = ¶meter.wire_name; let location = parameter.location.tokens(api_crate); Some(quote! { #api_crate::ParameterMetadata { rust_name: #rust_name, + rust_type: stringify!(#ty), wire_name: #wire_name, location: #location, } @@ -2349,6 +2679,7 @@ fn metadata_tokens( operation_id: #operation_id, method: #method, path: #path, + transport: #transport, parameters: &[#(#parameters),*], request_body: #api_crate::BodyMetadata { wire_kind: #request_kind }, success_responses: &[#(#success_responses),*], diff --git a/crates/api-macros/src/axum.rs b/crates/api-macros/src/axum.rs index f577245e2..8f8807277 100644 --- a/crates/api-macros/src/axum.rs +++ b/crates/api-macros/src/axum.rs @@ -8,9 +8,11 @@ use std::{fmt, str::FromStr}; use axum::{ - Json, + Json, Router, + handler::Handler, http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, + routing::get, }; use serde::Serialize; @@ -90,6 +92,26 @@ pub fn status(code: u16) -> StatusCode { StatusCode::from_u16(code).expect("#[api] validates HTTP status constants") } +/// Mount a manual Axum upgrade handler at a contract-declared WebSocket route. +/// +/// The operation marker supplies the only path and method authority. Requiring +/// [`crate::WebSocketOperation`] prevents HTTP operation markers from being registered through this +/// helper while Axum retains its normal route composition and collision behavior. +pub fn websocket_route(router: Router, handler: H) -> Router +where + O: crate::WebSocketOperation, + H: Handler, + T: 'static, + S: Clone + Send + Sync + 'static, +{ + debug_assert_eq!(O::METADATA.method, crate::HttpMethod::Get); + debug_assert!(matches!( + O::METADATA.transport, + crate::TransportMetadata::WebSocket { .. } + )); + router.route(O::METADATA.path, get(handler)) +} + /// Reexports used by generated router code. pub mod framework { pub use axum::{ diff --git a/crates/api-macros/src/lib.rs b/crates/api-macros/src/lib.rs index c9596b190..a3227f814 100644 --- a/crates/api-macros/src/lib.rs +++ b/crates/api-macros/src/lib.rs @@ -59,6 +59,34 @@ //! `openapi = false` explicitly excludes an operation whose wire body cannot yet satisfy the //! strict OpenAPI schema boundary; Reqwest and Axum adapters are still generated. //! +//! WebSocket routes use a declaration-only associated type rather than a dummy unary method: +//! +//! ``` +//! # mod websocket_example { +//! # use api_macros::api; +//! # pub struct ClientFrame; +//! # pub struct ServerFrame; +//! #[api] +//! pub trait EventApi { +//! #[websocket( +//! "/events/{stream_id}", +//! operation_id = "events.stream", +//! method = GET, +//! client_to_server = ClientFrame, +//! server_to_client = ServerFrame, +//! path_parameters = [stream_id: u64] +//! )] +//! type EventStream; +//! } +//! # } +//! ``` +//! +//! The declaration is removed from the emitted service trait and becomes a typed marker implementing +//! [`WebSocketOperation`]. It participates in the same operation/route inventory and path validation, +//! but never generates a Reqwest method, unary Axum adapter, or OpenAPI path. Under the `axum` +//! feature, [`axum::websocket_route`] mounts an existing manual upgrade handler at its declared GET +//! path without exposing framework WebSocket types to the contract. +//! //! # Generated names //! //! For `WidgetApi`, the macro emits `WidgetApiMetadata`, which implements [`ApiContract`], @@ -216,14 +244,27 @@ pub enum WireKind { Binary, } -/// Stable metadata for one Rust method argument. +/// Stable metadata for one operation argument. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub struct ParameterMetadata { pub rust_name: &'static str, + pub rust_type: &'static str, pub wire_name: &'static str, pub location: ParameterLocation, } +/// Transport semantics declared by one operation. +/// +/// WebSocket frame directions remain distinct even when both directions use the same Rust type. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum TransportMetadata { + Http, + WebSocket { + client_to_server_frame: &'static str, + server_to_client_frame: &'static str, + }, +} + /// Stable metadata for a request or response body. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub struct BodyMetadata { @@ -253,6 +294,7 @@ pub struct OperationMetadata { pub operation_id: &'static str, pub method: HttpMethod, pub path: &'static str, + pub transport: TransportMetadata, pub parameters: &'static [ParameterMetadata], pub request_body: BodyMetadata, /// Every declared successful response, including status-specific body and header shape. @@ -280,6 +322,21 @@ pub trait Operation { const METADATA: OperationMetadata; } +/// Compile-time connection between a WebSocket route and its directional frame authorities. +/// +/// This is intentionally separate from [`Operation`]: WebSocket routes do not have unary request, +/// response, or error bodies and therefore cannot be passed to HTTP adapter surfaces. +pub trait WebSocketOperation { + /// Tuple of path parameter types in path-template order. + type PathParameters; + /// Frame received by the server from the connected client. + type ClientToServerFrame; + /// Frame sent by the server to the connected client. + type ServerToClientFrame; + + const METADATA: OperationMetadata; +} + /// Public JSON error that can select its concrete HTTP status at the server boundary. /// /// `error_status` remains the contract's default/documented status. Implementations may return a diff --git a/crates/api-macros/tests/expansion.rs b/crates/api-macros/tests/expansion.rs index 7268e9a32..200b202e3 100644 --- a/crates/api-macros/tests/expansion.rs +++ b/crates/api-macros/tests/expansion.rs @@ -1,7 +1,8 @@ #![allow(async_fn_in_trait)] use api_macros::{ - ApiContract, BinaryBody, HttpMethod, NoBody, Operation, ParameterLocation, WireKind, api, + ApiContract, BinaryBody, HttpMethod, NoBody, Operation, ParameterLocation, TransportMetadata, + WebSocketOperation, WireKind, api, }; pub struct CreateWidget; @@ -9,6 +10,24 @@ pub struct Widget; pub struct Lookup; pub struct PublicError; pub struct SearchResult(std::marker::PhantomData); +pub struct ClientFrame; +pub struct ServerFrame; + +#[api] +pub trait MixedTransportApi { + #[get("/status")] + async fn status(&self) -> Widget; + + #[websocket( + "/widgets/{widget_id}/events", + operation_id = "widgets.events", + method = GET, + client_to_server = ClientFrame, + server_to_client = ServerFrame, + path_parameters = [widget_id: u64] + )] + type WidgetEvents; +} #[api] pub trait WidgetApi { @@ -106,11 +125,13 @@ fn expansion_exposes_deterministic_metadata_and_type_connections() { assert_eq!(operations[0], create); assert_eq!(create.method, HttpMethod::Post); assert_eq!(create.path, "/widgets"); + assert_eq!(create.transport, TransportMetadata::Http); assert_eq!(create.request_body.wire_kind, WireKind::Json); assert_eq!(create.success_responses[0].status, 201); assert_eq!(create.success_responses[0].body.wire_kind, WireKind::Json); assert_eq!(create.error_responses[0].status, 422); assert_eq!(create.parameters[0].location, ParameterLocation::Body); + assert_eq!(create.parameters[0].rust_type, "CreateWidget"); assert_eq!(create.parameters[1].location, ParameterLocation::Header); assert_eq!(create.parameters[1].wire_name, "x-request-id"); @@ -160,4 +181,28 @@ fn expansion_exposes_deterministic_metadata_and_type_connections() { fn assert_empty_response>() {} assert_empty_request::(); assert_empty_response::(); + + fn assert_websocket_types< + O: WebSocketOperation< + PathParameters = (u64,), + ClientToServerFrame = ClientFrame, + ServerToClientFrame = ServerFrame, + >, + >() { + } + assert_websocket_types::(); + let websocket = ::METADATA; + assert_eq!(websocket.method, HttpMethod::Get); + assert_eq!(websocket.path, "/widgets/{widget_id}/events"); + assert_eq!(websocket.parameters[0].rust_name, "widget_id"); + assert_eq!(websocket.parameters[0].rust_type, "u64"); + assert!(websocket.success_responses.is_empty()); + assert!(websocket.error_responses.is_empty()); + assert_eq!( + websocket.transport, + TransportMetadata::WebSocket { + client_to_server_frame: "ClientFrame", + server_to_client_frame: "ServerFrame", + } + ); } diff --git a/crates/api-macros/tests/http_adapters.rs b/crates/api-macros/tests/http_adapters.rs index 729af54f1..806550b3a 100644 --- a/crates/api-macros/tests/http_adapters.rs +++ b/crates/api-macros/tests/http_adapters.rs @@ -8,6 +8,26 @@ use std::{ use api_macros::{api, reqwest as api_reqwest}; use serde::{Deserialize, Serialize}; +pub struct NonSerdeClientFrame; +pub struct NonSerdeServerFrame; + +#[api(reqwest, axum)] +pub trait WebSocketOnlyApi { + #[websocket( + "/events/{stream_id}", + operation_id = "events.websocket", + method = GET, + client_to_server = NonSerdeClientFrame, + server_to_client = NonSerdeServerFrame, + path_parameters = [stream_id: u64] + )] + type Events; +} + +#[derive(Clone)] +pub struct WebSocketOnlyService; +impl WebSocketOnlyApi for WebSocketOnlyService {} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct CreateWidget { name: String, @@ -287,6 +307,40 @@ async fn slow() -> &'static str { "{}" } +async fn manual_websocket_route( + api_macros::axum::framework::Path(stream_id): api_macros::axum::framework::Path, +) -> String { + format!("stream:{stream_id}") +} + +#[tokio::test] +async fn websocket_declarations_skip_unary_adapters_and_mount_manual_handlers() { + let _client = WebSocketOnlyApiClient::try_new("http://127.0.0.1:1") + .expect("WebSocket-only contracts still provide a client with no unary methods"); + let app = api_macros::axum::websocket_route::( + api_macros::axum::framework::Router::new(), + manual_websocket_route, + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind fixture server"); + let address = listener.local_addr().expect("fixture address"); + let server = tokio::spawn(async move { + api_macros::axum::framework::serve(listener, app) + .await + .expect("serve fixture WebSocket route"); + }); + + let body = reqwest::get(format!("http://{address}/events/42")) + .await + .expect("manual route response") + .text() + .await + .expect("manual route body"); + assert_eq!(body, "stream:42"); + server.abort(); +} + #[tokio::test] async fn generated_client_and_router_follow_the_normalized_contract() { let app = WidgetApiAxum::router(WidgetService).merge( diff --git a/crates/api-macros/tests/openapi.rs b/crates/api-macros/tests/openapi.rs index ea75ee3a3..db9bc5e74 100644 --- a/crates/api-macros/tests/openapi.rs +++ b/crates/api-macros/tests/openapi.rs @@ -81,6 +81,9 @@ impl OpenApiSchema for PublicError {} type OptionalRequestId = Option; +pub struct ClientFrame; +pub struct ServerFrame; + #[api(openapi)] pub trait FixtureApi { #[post( @@ -113,6 +116,16 @@ pub trait FixtureApi { ] )] async fn conditional(&self) -> fixture_api_responses::Conditional; + + #[websocket( + "/widgets/{widget_id}/events", + operation_id = "widgets.events", + method = GET, + client_to_server = ClientFrame, + server_to_client = ServerFrame, + path_parameters = [widget_id: u32] + )] + type WidgetEvents; } fn document() -> api_macros::openapi::OpenApiDocument { @@ -188,6 +201,10 @@ fn collect_schema_references<'a>(value: &'a Value, references: &mut Vec<&'a str> #[test] fn operations_and_components_preserve_the_wire_contract() { let value = document().as_value().clone(); + assert!( + value["paths"]["/widgets/{widget_id}/events"].is_null(), + "WebSocket transports must not be projected as unary OpenAPI operations" + ); let create = &value["paths"]["/widgets/{widget_id}"]["post"]; assert_eq!(create["operationId"], "widgets.create"); assert_eq!(create["security"], json!([{ "bearerAuth": [] }])); diff --git a/crates/api-macros/tests/ui/duplicate_websocket_path_parameter.rs b/crates/api-macros/tests/ui/duplicate_websocket_path_parameter.rs new file mode 100644 index 000000000..d9b8ea4bd --- /dev/null +++ b/crates/api-macros/tests/ui/duplicate_websocket_path_parameter.rs @@ -0,0 +1,17 @@ +use api_macros::api; + +pub struct Frame; + +#[api] +pub trait DuplicateWebSocketPathParameterApi { + #[websocket( + "/widgets/{widget_id}/events", + method = GET, + client_to_server = Frame, + server_to_client = Frame, + path_parameters = [widget_id: u64, widget_id: String] + )] + type Events; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/duplicate_websocket_path_parameter.stderr b/crates/api-macros/tests/ui/duplicate_websocket_path_parameter.stderr new file mode 100644 index 000000000..09c4ce60a --- /dev/null +++ b/crates/api-macros/tests/ui/duplicate_websocket_path_parameter.stderr @@ -0,0 +1,5 @@ +error: duplicate path parameter `widget_id` + --> tests/ui/duplicate_websocket_path_parameter.rs:12:44 + | +12 | path_parameters = [widget_id: u64, widget_id: String] + | ^^^^^^^^^ diff --git a/crates/api-macros/tests/ui/duplicate_websocket_route.rs b/crates/api-macros/tests/ui/duplicate_websocket_route.rs new file mode 100644 index 000000000..353b545f0 --- /dev/null +++ b/crates/api-macros/tests/ui/duplicate_websocket_route.rs @@ -0,0 +1,21 @@ +use api_macros::api; + +pub struct Frame; +pub struct Response; + +#[api] +pub trait DuplicateTransportRouteApi { + #[get("/widgets/{widget_id}/events")] + async fn events(&self, #[path] widget_id: u64) -> Response; + + #[websocket( + "/widgets/{widget_id}/events", + method = GET, + client_to_server = Frame, + server_to_client = Frame, + path_parameters = [widget_id: u64] + )] + type WidgetEvents; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/duplicate_websocket_route.stderr b/crates/api-macros/tests/ui/duplicate_websocket_route.stderr new file mode 100644 index 000000000..e10d9389e --- /dev/null +++ b/crates/api-macros/tests/ui/duplicate_websocket_route.stderr @@ -0,0 +1,5 @@ +error: duplicate route for GET `/widgets/{widget_id}/events` + --> tests/ui/duplicate_websocket_route.rs:18:10 + | +18 | type WidgetEvents; + | ^^^^^^^^^^^^ diff --git a/crates/api-macros/tests/ui/invalid_websocket_alternate_status.rs b/crates/api-macros/tests/ui/invalid_websocket_alternate_status.rs new file mode 100644 index 000000000..7eb7ff1c2 --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_websocket_alternate_status.rs @@ -0,0 +1,18 @@ +use api_macros::api; + +pub struct Frame; + +#[api] +pub trait InvalidWebSocketAlternateStatusApi { + #[websocket( + "/events", + method = GET, + client_to_server = Frame, + server_to_client = Frame, + path_parameters = [], + alternate_status = 204 + )] + type Events; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/invalid_websocket_alternate_status.stderr b/crates/api-macros/tests/ui/invalid_websocket_alternate_status.stderr new file mode 100644 index 000000000..7dfbd16f7 --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_websocket_alternate_status.stderr @@ -0,0 +1,5 @@ +error: WebSocket operations cannot declare unary success responses or statuses + --> tests/ui/invalid_websocket_alternate_status.rs:13:9 + | +13 | alternate_status = 204 + | ^^^^^^^^^^^^^^^^ diff --git a/crates/api-macros/tests/ui/invalid_websocket_binary_body.rs b/crates/api-macros/tests/ui/invalid_websocket_binary_body.rs new file mode 100644 index 000000000..2de2e719a --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_websocket_binary_body.rs @@ -0,0 +1,19 @@ +use api_macros::api; + +pub struct Frame; +pub struct Body; + +#[api] +pub trait InvalidWebSocketBinaryBodyApi { + #[websocket( + "/events", + method = GET, + client_to_server = Frame, + server_to_client = Frame, + path_parameters = [], + binary = Body + )] + type Events; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/invalid_websocket_binary_body.stderr b/crates/api-macros/tests/ui/invalid_websocket_binary_body.stderr new file mode 100644 index 000000000..4975f4300 --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_websocket_binary_body.stderr @@ -0,0 +1,5 @@ +error: WebSocket operations cannot declare JSON or binary request bodies + --> tests/ui/invalid_websocket_binary_body.rs:14:9 + | +14 | binary = Body + | ^^^^^^ diff --git a/crates/api-macros/tests/ui/invalid_websocket_body.rs b/crates/api-macros/tests/ui/invalid_websocket_body.rs new file mode 100644 index 000000000..72c38ceb5 --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_websocket_body.rs @@ -0,0 +1,19 @@ +use api_macros::api; + +pub struct Frame; +pub struct Body; + +#[api] +pub trait InvalidWebSocketBodyApi { + #[websocket( + "/events", + method = GET, + client_to_server = Frame, + server_to_client = Frame, + path_parameters = [], + body = Body + )] + type Events; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/invalid_websocket_body.stderr b/crates/api-macros/tests/ui/invalid_websocket_body.stderr new file mode 100644 index 000000000..6433a24f6 --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_websocket_body.stderr @@ -0,0 +1,5 @@ +error: WebSocket operations cannot declare JSON or binary request bodies + --> tests/ui/invalid_websocket_body.rs:14:9 + | +14 | body = Body + | ^^^^ diff --git a/crates/api-macros/tests/ui/invalid_websocket_frame.rs b/crates/api-macros/tests/ui/invalid_websocket_frame.rs new file mode 100644 index 000000000..3950d95e2 --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_websocket_frame.rs @@ -0,0 +1,17 @@ +use api_macros::api; + +pub struct Frame; + +#[api] +pub trait InvalidWebSocketFrameApi { + #[websocket( + "/events", + method = GET, + client_to_server = (Frame, Frame), + server_to_client = Frame, + path_parameters = [] + )] + type Events; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/invalid_websocket_frame.stderr b/crates/api-macros/tests/ui/invalid_websocket_frame.stderr new file mode 100644 index 000000000..85ff68cb4 --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_websocket_frame.stderr @@ -0,0 +1,5 @@ +error: WebSocket frame authorities must use direct named Rust types + --> tests/ui/invalid_websocket_frame.rs:10:28 + | +10 | client_to_server = (Frame, Frame), + | ^^^^^^^^^^^^^^ diff --git a/crates/api-macros/tests/ui/invalid_websocket_method.rs b/crates/api-macros/tests/ui/invalid_websocket_method.rs new file mode 100644 index 000000000..a5c9fa289 --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_websocket_method.rs @@ -0,0 +1,17 @@ +use api_macros::api; + +pub struct Frame; + +#[api] +pub trait InvalidWebSocketMethodApi { + #[websocket( + "/events", + method = POST, + client_to_server = Frame, + server_to_client = Frame, + path_parameters = [] + )] + type Events; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/invalid_websocket_method.stderr b/crates/api-macros/tests/ui/invalid_websocket_method.stderr new file mode 100644 index 000000000..cefbd9834 --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_websocket_method.stderr @@ -0,0 +1,5 @@ +error: WebSocket operations require `method = GET` + --> tests/ui/invalid_websocket_method.rs:9:18 + | +9 | method = POST, + | ^^^^ diff --git a/crates/api-macros/tests/ui/invalid_websocket_response.rs b/crates/api-macros/tests/ui/invalid_websocket_response.rs new file mode 100644 index 000000000..75ed0d613 --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_websocket_response.rs @@ -0,0 +1,18 @@ +use api_macros::api; + +pub struct Frame; + +#[api] +pub trait InvalidWebSocketResponseApi { + #[websocket( + "/events", + method = GET, + client_to_server = Frame, + server_to_client = Frame, + path_parameters = [], + status = 101 + )] + type Events; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/invalid_websocket_response.stderr b/crates/api-macros/tests/ui/invalid_websocket_response.stderr new file mode 100644 index 000000000..1270a0a24 --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_websocket_response.stderr @@ -0,0 +1,5 @@ +error: WebSocket operations cannot declare unary success responses or statuses + --> tests/ui/invalid_websocket_response.rs:13:9 + | +13 | status = 101 + | ^^^^^^ diff --git a/crates/api-macros/tests/ui/invalid_websocket_response_body.rs b/crates/api-macros/tests/ui/invalid_websocket_response_body.rs new file mode 100644 index 000000000..30a5e8a96 --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_websocket_response_body.rs @@ -0,0 +1,19 @@ +use api_macros::api; + +pub struct Frame; +pub struct Response; + +#[api] +pub trait InvalidWebSocketResponseBodyApi { + #[websocket( + "/events", + method = GET, + client_to_server = Frame, + server_to_client = Frame, + path_parameters = [], + response = Response + )] + type Events; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/invalid_websocket_response_body.stderr b/crates/api-macros/tests/ui/invalid_websocket_response_body.stderr new file mode 100644 index 000000000..f8b499716 --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_websocket_response_body.stderr @@ -0,0 +1,5 @@ +error: WebSocket operations cannot declare unary success responses or statuses + --> tests/ui/invalid_websocket_response_body.rs:14:9 + | +14 | response = Response + | ^^^^^^^^ diff --git a/crates/api-macros/tests/ui/missing_websocket_frame.rs b/crates/api-macros/tests/ui/missing_websocket_frame.rs new file mode 100644 index 000000000..9561e7e0e --- /dev/null +++ b/crates/api-macros/tests/ui/missing_websocket_frame.rs @@ -0,0 +1,16 @@ +use api_macros::api; + +pub struct Frame; + +#[api] +pub trait MissingWebSocketFrameApi { + #[websocket( + "/events", + method = GET, + client_to_server = Frame, + path_parameters = [] + )] + type Events; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/missing_websocket_frame.stderr b/crates/api-macros/tests/ui/missing_websocket_frame.stderr new file mode 100644 index 000000000..25f27a29a --- /dev/null +++ b/crates/api-macros/tests/ui/missing_websocket_frame.stderr @@ -0,0 +1,5 @@ +error: WebSocket operations require `server_to_client = FrameType` + --> tests/ui/missing_websocket_frame.rs:8:9 + | +8 | "/events", + | ^^^^^^^^^ diff --git a/crates/api-macros/tests/ui/websocket_path_mismatch.rs b/crates/api-macros/tests/ui/websocket_path_mismatch.rs new file mode 100644 index 000000000..23f67b75d --- /dev/null +++ b/crates/api-macros/tests/ui/websocket_path_mismatch.rs @@ -0,0 +1,17 @@ +use api_macros::api; + +pub struct Frame; + +#[api] +pub trait InvalidWebSocketPathApi { + #[websocket( + "/widgets/{widget_id}/events", + method = GET, + client_to_server = Frame, + server_to_client = Frame, + path_parameters = [other_id: u64] + )] + type Events; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/websocket_path_mismatch.stderr b/crates/api-macros/tests/ui/websocket_path_mismatch.stderr new file mode 100644 index 000000000..8f6b75609 --- /dev/null +++ b/crates/api-macros/tests/ui/websocket_path_mismatch.stderr @@ -0,0 +1,5 @@ +error: path placeholder(s) have no matching path parameter: widget_id + --> tests/ui/websocket_path_mismatch.rs:8:9 + | +8 | "/widgets/{widget_id}/events", + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/crates/api-macros/tests/ui/websocket_unused_path_parameter.rs b/crates/api-macros/tests/ui/websocket_unused_path_parameter.rs new file mode 100644 index 000000000..ba91d139f --- /dev/null +++ b/crates/api-macros/tests/ui/websocket_unused_path_parameter.rs @@ -0,0 +1,17 @@ +use api_macros::api; + +pub struct Frame; + +#[api] +pub trait UnusedWebSocketPathParameterApi { + #[websocket( + "/events", + method = GET, + client_to_server = Frame, + server_to_client = Frame, + path_parameters = [unused: u64] + )] + type Events; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/websocket_unused_path_parameter.stderr b/crates/api-macros/tests/ui/websocket_unused_path_parameter.stderr new file mode 100644 index 000000000..49e24c040 --- /dev/null +++ b/crates/api-macros/tests/ui/websocket_unused_path_parameter.stderr @@ -0,0 +1,5 @@ +error: path parameter(s) have no matching placeholder: unused + --> tests/ui/websocket_unused_path_parameter.rs:8:9 + | +8 | "/events", + | ^^^^^^^^^ diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index e5f7a9071..e423987ae 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -9,7 +9,7 @@ use std::collections::BTreeMap; 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, BinaryBody, HttpMethod}; +pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, WebSocketOperation}; pub mod repository_openapi_typescript; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -2885,10 +2885,147 @@ pub trait ServerApi { #[path] objective_id: String, #[path] ticket_id: String, ) -> Result; + + #[websocket( + "/api/w/{workspace_id}/protocol/ws", + operation_id = "workspace_protocol_websocket", + method = GET, + client_to_server = protocol::subscription::SubscriptionFrame, + server_to_client = protocol::subscription::SubscriptionFrame, + path_parameters = [workspace_id: String] + )] + type WorkspaceProtocolWebSocket; + + #[websocket( + "/api/w/{workspace_id}/external-workdir-grants/{grant_id}/provider", + operation_id = "external_workdir_provider_websocket", + method = GET, + client_to_server = workdir::external::ExternalWorkdirProviderFrame, + server_to_client = workdir::external::ExternalWorkdirServerFrame, + path_parameters = [workspace_id: String, grant_id: String] + )] + type ExternalWorkdirProviderWebSocket; + + #[websocket( + "/api/runtimes/{runtime_id}/workers/{worker_id}/protocol/ws", + operation_id = "worker_protocol_websocket_alias", + method = GET, + client_to_server = protocol::Method, + server_to_client = protocol::Event, + path_parameters = [runtime_id: String, worker_id: String] + )] + type WorkerProtocolWebSocketAlias; + + #[websocket( + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/protocol/ws", + operation_id = "workspace_worker_protocol_websocket", + method = GET, + client_to_server = protocol::Method, + server_to_client = protocol::Event, + path_parameters = [workspace_id: String, runtime_id: String, worker_id: String] + )] + type WorkspaceWorkerProtocolWebSocket; } -/// Digest of the fully rendered canonical contract with its digest slot normalized. -pub fn canonical_openapi_source_digest() -> Result { +/// Stable fingerprint for one complete operation declaration. +/// +/// The fingerprint includes transport kind, directional WebSocket frame authorities, path +/// parameter names and types, and every unary body/response shape. Length-prefixing keeps the +/// representation unambiguous without introducing a second schema format. +pub fn operation_fingerprint(operation: &api_macros::OperationMetadata) -> String { + fn field(hasher: &mut Sha256, value: &str) { + hasher.update((value.len() as u64).to_be_bytes()); + hasher.update(value.as_bytes()); + } + + let mut hasher = Sha256::new(); + field(&mut hasher, operation.operation_id); + field(&mut hasher, http_method_name(operation.method)); + field(&mut hasher, operation.path); + match operation.transport { + api_macros::TransportMetadata::Http => field(&mut hasher, "http"), + api_macros::TransportMetadata::WebSocket { + client_to_server_frame, + server_to_client_frame, + } => { + field(&mut hasher, "websocket"); + field(&mut hasher, client_to_server_frame); + field(&mut hasher, server_to_client_frame); + } + } + field(&mut hasher, "parameters"); + field(&mut hasher, &operation.parameters.len().to_string()); + for parameter in operation.parameters { + field(&mut hasher, parameter.rust_name); + field(&mut hasher, parameter.rust_type); + field(&mut hasher, parameter.wire_name); + field(&mut hasher, parameter_location_name(parameter.location)); + } + field( + &mut hasher, + wire_kind_name(operation.request_body.wire_kind), + ); + field(&mut hasher, "successes"); + field(&mut hasher, &operation.success_responses.len().to_string()); + for response in operation.success_responses { + field(&mut hasher, &response.status.to_string()); + field(&mut hasher, wire_kind_name(response.body.wire_kind)); + field(&mut hasher, &response.headers.len().to_string()); + for header in response.headers { + field(&mut hasher, header.wire_name); + field(&mut hasher, header.rust_type); + } + } + field(&mut hasher, "errors"); + field(&mut hasher, &operation.error_responses.len().to_string()); + for response in operation.error_responses { + field(&mut hasher, &response.status.to_string()); + field(&mut hasher, wire_kind_name(response.body.wire_kind)); + } + let digest = hasher.finalize(); + 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 +} + +fn http_method_name(method: HttpMethod) -> &'static str { + match method { + HttpMethod::Get => "GET", + HttpMethod::Post => "POST", + HttpMethod::Put => "PUT", + HttpMethod::Patch => "PATCH", + HttpMethod::Delete => "DELETE", + HttpMethod::Head => "HEAD", + HttpMethod::Options => "OPTIONS", + } +} + +fn parameter_location_name(location: api_macros::ParameterLocation) -> &'static str { + match location { + api_macros::ParameterLocation::Path => "path", + api_macros::ParameterLocation::Query => "query", + api_macros::ParameterLocation::Header => "header", + api_macros::ParameterLocation::Body => "body", + } +} + +fn wire_kind_name(kind: api_macros::WireKind) -> &'static str { + match kind { + api_macros::WireKind::Empty => "empty", + api_macros::WireKind::Json => "json", + api_macros::WireKind::Streaming => "streaming", + api_macros::WireKind::Multipart => "multipart", + api_macros::WireKind::Binary => "binary", + _ => "reserved", + } +} + +/// Digest of the complete canonical contract with the OpenAPI digest slot normalized. +pub fn canonical_contract_source_digest() -> Result { const NORMALIZED_DIGEST: &str = "sha256:0000000000000000000000000000000000000000000000000000000000000000"; let normalized = server_api_openapi(api_macros::openapi::OpenApiInfo { @@ -2897,7 +3034,15 @@ pub fn canonical_openapi_source_digest() -> Result Result Result { - let source_digest = canonical_openapi_source_digest()?; + let source_digest = canonical_contract_source_digest()?; server_api_openapi(api_macros::openapi::OpenApiInfo { title: "Yoi Server API", version: env!("CARGO_PKG_VERSION"), @@ -9124,6 +9269,128 @@ mod skill_typescript_tests { mod tests { use super::*; + #[test] + fn websocket_operations_preserve_routes_and_directional_frame_authorities() { + use api_macros::{TransportMetadata, WebSocketOperation}; + + fn workspace_protocol() + where + O: WebSocketOperation< + PathParameters = (String,), + ClientToServerFrame = protocol::subscription::SubscriptionFrame, + ServerToClientFrame = protocol::subscription::SubscriptionFrame, + >, + { + } + fn external_provider() + where + O: WebSocketOperation< + PathParameters = (String, String), + ClientToServerFrame = workdir::external::ExternalWorkdirProviderFrame, + ServerToClientFrame = workdir::external::ExternalWorkdirServerFrame, + >, + { + } + fn direct_worker() + where + O: WebSocketOperation< + PathParameters = P, + ClientToServerFrame = protocol::Method, + ServerToClientFrame = protocol::Event, + >, + { + } + + workspace_protocol::(); + external_provider::(); + direct_worker::(); + direct_worker::< + server_api_operations::WorkspaceWorkerProtocolWebSocket, + (String, String, String), + >(); + + for (operation_id, path, client, server) in [ + ( + "workspace_protocol_websocket", + "/api/w/{workspace_id}/protocol/ws", + "protocol :: subscription :: SubscriptionFrame", + "protocol :: subscription :: SubscriptionFrame", + ), + ( + "external_workdir_provider_websocket", + "/api/w/{workspace_id}/external-workdir-grants/{grant_id}/provider", + "workdir :: external :: ExternalWorkdirProviderFrame", + "workdir :: external :: ExternalWorkdirServerFrame", + ), + ( + "worker_protocol_websocket_alias", + "/api/runtimes/{runtime_id}/workers/{worker_id}/protocol/ws", + "protocol :: Method", + "protocol :: Event", + ), + ( + "workspace_worker_protocol_websocket", + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/protocol/ws", + "protocol :: Method", + "protocol :: Event", + ), + ] { + let operation = ServerApiMetadata::OPERATIONS + .iter() + .find(|operation| operation.operation_id == operation_id) + .unwrap_or_else(|| panic!("missing WebSocket operation {operation_id}")); + assert_eq!(operation.method, HttpMethod::Get); + assert_eq!(operation.path, path); + assert!(operation.success_responses.is_empty()); + assert!(operation.error_responses.is_empty()); + assert_eq!( + operation.request_body.wire_kind, + api_macros::WireKind::Empty + ); + assert_eq!( + operation.transport, + TransportMetadata::WebSocket { + client_to_server_frame: client, + server_to_client_frame: server, + } + ); + assert!(operation_fingerprint(operation).starts_with("sha256:")); + } + + let external = *ServerApiMetadata::OPERATIONS + .iter() + .find(|operation| operation.operation_id == "external_workdir_provider_websocket") + .expect("external provider WebSocket operation"); + let swapped = api_macros::OperationMetadata { + transport: TransportMetadata::WebSocket { + client_to_server_frame: "workdir :: external :: ExternalWorkdirServerFrame", + server_to_client_frame: "workdir :: external :: ExternalWorkdirProviderFrame", + }, + ..external + }; + assert_ne!( + operation_fingerprint(&external), + operation_fingerprint(&swapped), + "directional frame authority must participate in the operation fingerprint" + ); + + let openapi = canonical_openapi_document() + .expect("canonical OpenAPI contract must build") + .as_value() + .clone(); + for path in [ + "/api/w/{workspace_id}/protocol/ws", + "/api/w/{workspace_id}/external-workdir-grants/{grant_id}/provider", + "/api/runtimes/{runtime_id}/workers/{worker_id}/protocol/ws", + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/protocol/ws", + ] { + assert!( + openapi["paths"][path].is_null(), + "WebSocket route {path} must not be projected into OpenAPI" + ); + } + } + #[test] fn runtime_config_response_round_trips_the_complete_config_bundle_shape() { let fixture = serde_json::json!({ @@ -11006,7 +11273,10 @@ mod openapi_artifact_tests { } } - let operations = ServerApiMetadata::OPERATIONS; + let operations = ServerApiMetadata::OPERATIONS + .iter() + .filter(|operation| matches!(operation.transport, api_macros::TransportMetadata::Http)) + .collect::>(); assert_eq!( documented.len() + SIGNED_INTERNAL.len() + OTHER_OPENAPI_EXCLUDED.len(), operations.len(), diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index eff017198..a690d6219 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -8311,28 +8311,34 @@ impl server_api::ServerApi for ServerApiContractService { fn build_inner_router(api: WorkspaceApi) -> Router { let contract_service = ServerApiContractService::Workspace(api.clone()); let auth = generated_auth_contract_router(contract_service.clone()); - let workspace = Router::new() - // WebSocket provider transport is intentionally outside the generated - // JSON REST contract. - .route( - "/api/w/{workspace_id}/external-workdir-grants/{grant_id}/provider", - get(scoped_external_workdir_provider_ws), - ) - .route( - "/api/w/{workspace_id}/protocol/ws", - get(scoped_workspace_protocol_ws), - ) - .route( - "/api/runtimes/{runtime_id}/workers/{worker_id}/protocol/ws", - get(worker_protocol_ws), - ) - .route( - "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/protocol/ws", - get(scoped_worker_protocol_ws), - ) - .fallback(get(static_or_spa_fallback)) - .with_state(api) - .merge(generated_workspace_contract_router(contract_service)); + let workspace = Router::new(); + let workspace = server_api::server_support::websocket_route::< + server_api::server_api_operations::ExternalWorkdirProviderWebSocket, + _, + _, + _, + >(workspace, scoped_external_workdir_provider_ws); + let workspace = server_api::server_support::websocket_route::< + server_api::server_api_operations::WorkspaceProtocolWebSocket, + _, + _, + _, + >(workspace, scoped_workspace_protocol_ws); + let workspace = server_api::server_support::websocket_route::< + server_api::server_api_operations::WorkerProtocolWebSocketAlias, + _, + _, + _, + >(workspace, worker_protocol_ws); + let workspace = server_api::server_support::websocket_route::< + server_api::server_api_operations::WorkspaceWorkerProtocolWebSocket, + _, + _, + _, + >(workspace, scoped_worker_protocol_ws) + .fallback(get(static_or_spa_fallback)) + .with_state(api) + .merge(generated_workspace_contract_router(contract_service)); auth.merge(workspace) .layer(middleware::from_fn(log_failed_api_response)) } diff --git a/openapi/server-api.json b/openapi/server-api.json index 3c66a0a8a..b3e54e2de 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -13893,7 +13893,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:2463eb461342aceb84c87e7aa6bdcf73e28e3866349a555bd4a0d795db671a3f" + "x-yoi-source-digest": "sha256:7a9d11f50cc41eca55a6dded4b8f55aa6d23b1beb2dc69b0a9b6c8349efc894f" }, "openapi": "3.1.0", "paths": { diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index f32f2c93d..0e9ff5ee1 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:2463eb461342aceb84c87e7aa6bdcf73e28e3866349a555bd4a0d795db671a3f) +// Input contract: 0.1.0 (sha256:7a9d11f50cc41eca55a6dded4b8f55aa6d23b1beb2dc69b0a9b6c8349efc894f) // 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 85db578b9..e3281a8bb 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:2463eb461342aceb84c87e7aa6bdcf73e28e3866349a555bd4a0d795db671a3f) +// Input contract: 0.1.0 (sha256:7a9d11f50cc41eca55a6dded4b8f55aa6d23b1beb2dc69b0a9b6c8349efc894f) // 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 f9d1294a9..0ddc4c37b 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:2463eb461342aceb84c87e7aa6bdcf73e28e3866349a555bd4a0d795db671a3f) +// Input contract: 0.1.0 (sha256:7a9d11f50cc41eca55a6dded4b8f55aa6d23b1beb2dc69b0a9b6c8349efc894f) // 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 d62132ce1..3c2c65901 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:2463eb461342aceb84c87e7aa6bdcf73e28e3866349a555bd4a0d795db671a3f) +// Input contract: 0.1.0 (sha256:7a9d11f50cc41eca55a6dded4b8f55aa6d23b1beb2dc69b0a9b6c8349efc894f) // 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 f828a9ecf..e0874bdac 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:2463eb461342aceb84c87e7aa6bdcf73e28e3866349a555bd4a0d795db671a3f) +// Input contract: 0.1.0 (sha256:7a9d11f50cc41eca55a6dded4b8f55aa6d23b1beb2dc69b0a9b6c8349efc894f) // 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 ce6a000c1..fd53da9d9 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:2463eb461342aceb84c87e7aa6bdcf73e28e3866349a555bd4a0d795db671a3f) +// Input contract: 0.1.0 (sha256:7a9d11f50cc41eca55a6dded4b8f55aa6d23b1beb2dc69b0a9b6c8349efc894f) // 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