feat(api): declare typed WebSocket operations

This commit is contained in:
2026-09-23 21:07:20 +09:00
parent ed99be3e24
commit 9c107eaf74
39 changed files with 1144 additions and 67 deletions
+360 -29
View File
@@ -26,6 +26,12 @@ pub fn api(attr: TokenStream, item: TokenStream) -> TokenStream {
.into() .into()
} }
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
enum Transport {
Http,
WebSocket,
}
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
enum Method { enum Method {
Get, Get,
@@ -114,6 +120,98 @@ struct RouteArgs {
openapi: Option<syn::LitBool>, openapi: Option<syn::LitBool>,
} }
struct WebSocketArgs {
path: LitStr,
operation_id: Option<LitStr>,
method: Option<Ident>,
client_to_server: Option<Type>,
server_to_client: Option<Type>,
path_parameters: Vec<(Ident, Type)>,
}
impl Parse for WebSocketArgs {
fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
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::<Token![,]>()?;
if input.is_empty() {
break;
}
let key: Ident = input.parse()?;
input.parse::<Token![=]>()?;
match key.to_string().as_str() {
"operation_id" => set_once(
&mut result.operation_id,
input.parse::<LitStr>()?,
&key,
"operation_id",
)?,
"method" => set_once(&mut result.method, input.parse::<Ident>()?, &key, "method")?,
"client_to_server" => set_once(
&mut result.client_to_server,
input.parse::<Type>()?,
&key,
"client_to_server",
)?,
"server_to_client" => set_once(
&mut result.server_to_client,
input.parse::<Type>()?,
&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::<Ident>()?;
content.parse::<Token![:]>()?;
let ty = content.parse::<Type>()?;
result.path_parameters.push((name, ty));
if !content.is_empty() {
content.parse::<Token![,]>()?;
}
}
}
"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 { struct DeclaredResponseHeader {
name: LitStr, name: LitStr,
field_ident: Ident, field_ident: Ident,
@@ -381,9 +479,12 @@ struct Operation {
method_ident: Ident, method_ident: Ident,
marker_ident: Ident, marker_ident: Ident,
operation_id: String, operation_id: String,
transport: Transport,
method: Method, method: Method,
path: String, path: String,
parameters: Vec<Parameter>, parameters: Vec<Parameter>,
client_to_server_frame: Option<Type>,
server_to_client_frame: Option<Type>,
request_body: Option<RequestBody>, request_body: Option<RequestBody>,
response_body: Option<Type>, response_body: Option<Type>,
success_responses: Vec<SuccessResponse>, success_responses: Vec<SuccessResponse>,
@@ -434,24 +535,36 @@ fn normalize_api(
let mut routes = BTreeMap::<(Method, String), Span>::new(); let mut routes = BTreeMap::<(Method, String), Span>::new();
let mut marker_names = BTreeMap::<String, (String, Span)>::new(); let mut marker_names = BTreeMap::<String, (String, Span)>::new();
for trait_item in &mut item.items { let mut retained_items = Vec::new();
let TraitItem::Fn(method) = trait_item else { for mut trait_item in std::mem::take(&mut item.items) {
return Err(syn::Error::new_spanned( let operation = match &mut trait_item {
trait_item, TraitItem::Fn(method) => {
"an #[api] trait may contain methods only", 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(); let marker_name = operation.marker_ident.to_string();
if let Some((first_method, first_span)) = marker_names.insert( if let Some((first_operation, first_span)) =
marker_name.clone(), marker_names.insert(marker_name.clone(), (source_name, source_span))
(ident_text(&method.sig.ident), method.sig.ident.span()), {
) {
let mut error = syn::Error::new( let mut error = syn::Error::new(
method.sig.ident.span(), source_span,
format!( 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( error.combine(syn::Error::new(
@@ -462,23 +575,20 @@ fn normalize_api(
} }
if operation_ids if operation_ids
.insert(operation.operation_id.clone(), method.sig.ident.span()) .insert(operation.operation_id.clone(), source_span)
.is_some() .is_some()
{ {
return Err(syn::Error::new( return Err(syn::Error::new(
method.sig.ident.span(), source_span,
format!("duplicate operation ID `{}`", operation.operation_id), format!("duplicate operation ID `{}`", operation.operation_id),
)); ));
} }
if routes if routes
.insert( .insert((operation.method, operation.path.clone()), source_span)
(operation.method, operation.path.clone()),
method.sig.ident.span(),
)
.is_some() .is_some()
{ {
return Err(syn::Error::new( return Err(syn::Error::new(
method.sig.ident.span(), source_span,
format!( format!(
"duplicate route for {} `{}`", "duplicate route for {} `{}`",
method_name(operation.method), method_name(operation.method),
@@ -488,6 +598,7 @@ fn normalize_api(
} }
operations.push(operation); operations.push(operation);
} }
item.items = retained_items;
if operations.is_empty() { if operations.is_empty() {
return Err(syn::Error::new_spanned( return Err(syn::Error::new_spanned(
@@ -528,6 +639,156 @@ fn make_service_futures_send(item: &mut ItemTrait) -> syn::Result<()> {
Ok(()) 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<Operation> {
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::<WebSocketArgs>()?);
} 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<Operation> { fn normalize_operation(method: &mut TraitItemFn) -> syn::Result<Operation> {
validate_signature(method)?; validate_signature(method)?;
@@ -912,9 +1173,12 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result<Operation> {
marker_ident: operation_marker_ident(&method.sig.ident), marker_ident: operation_marker_ident(&method.sig.ident),
method_ident: method.sig.ident.clone(), method_ident: method.sig.ident.clone(),
operation_id, operation_id,
transport: Transport::Http,
method: http_method, method: http_method,
path, path,
parameters, parameters,
client_to_server_frame: None,
server_to_client_frame: None,
request_body: body_type, request_body: body_type,
response_body, response_body,
success_responses, success_responses,
@@ -1379,7 +1643,11 @@ fn reqwest_adapter_tokens(
to_snake_case(trait_ident), to_snake_case(trait_ident),
span = trait_ident.span() 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 method_ident = &operation.method_ident;
let arguments = operation.parameters.iter().filter_map(|parameter| { let arguments = operation.parameters.iter().filter_map(|parameter| {
if matches!(parameter.location, Location::Extension) { if matches!(parameter.location, Location::Extension) {
@@ -1716,7 +1984,11 @@ fn axum_adapter_tokens(
to_snake_case(trait_ident), to_snake_case(trait_ident),
span = trait_ident.span() 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 method_ident = &operation.method_ident;
let handler_ident = format_ident!("{}_handler", ident_text(method_ident)); let handler_ident = format_ident!("{}_handler", ident_text(method_ident));
let route_path = &operation.path; let route_path = &operation.path;
@@ -1959,10 +2231,14 @@ fn axum_adapter_tokens(
} }
} }
}).collect(); }).collect();
let operation_merges = api.operations.iter().map(|operation| { let operation_merges = api
let method_ident = &operation.method_ident; .operations
quote!(__router = __router.merge(#module_ident::#method_ident(__service.clone()));) .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! { quote! {
#[doc = concat!("Per-operation Axum routers for [`", stringify!(#trait_ident), "`].")] #[doc = concat!("Per-operation Axum routers for [`", stringify!(#trait_ident), "`].")]
@@ -2005,7 +2281,9 @@ fn openapi_adapter_tokens(
let operations = api let operations = api
.operations .operations
.iter() .iter()
.filter(|operation| !operation.openapi_skip) .filter(|operation| {
matches!(operation.transport, Transport::Http) && !operation.openapi_skip
})
.map(|operation| { .map(|operation| {
let method = method_name(operation.method); let method = method_name(operation.method);
let path = &operation.path; let path = &operation.path;
@@ -2188,9 +2466,9 @@ fn expand_api(api: ApiDefinition) -> syn::Result<proc_macro2::TokenStream> {
.iter() .iter()
.map(|operation| { .map(|operation| {
let marker_ident = &operation.marker_ident; let marker_ident = &operation.marker_ident;
let method_ident = &operation.method_ident; let declaration_ident = &operation.method_ident;
quote! { 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)] #[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
pub struct #marker_ident; pub struct #marker_ident;
} }
@@ -2199,6 +2477,7 @@ fn expand_api(api: ApiDefinition) -> syn::Result<proc_macro2::TokenStream> {
let operation_impls: Vec<_> = api let operation_impls: Vec<_> = api
.operations .operations
.iter() .iter()
.filter(|operation| matches!(operation.transport, Transport::Http))
.map(|operation| { .map(|operation| {
let marker_ident = &operation.marker_ident; let marker_ident = &operation.marker_ident;
let metadata = metadata_tokens(operation, &api_crate); let metadata = metadata_tokens(operation, &api_crate);
@@ -2239,6 +2518,35 @@ fn expand_api(api: ApiDefinition) -> syn::Result<proc_macro2::TokenStream> {
} }
}) })
.collect(); .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| &parameter.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 let inventory: Vec<_> = api
.operations .operations
.iter() .iter()
@@ -2271,6 +2579,7 @@ fn expand_api(api: ApiDefinition) -> syn::Result<proc_macro2::TokenStream> {
} }
#(#operation_impls)* #(#operation_impls)*
#(#websocket_operation_impls)*
#reqwest_adapter #reqwest_adapter
#axum_adapter #axum_adapter
@@ -2285,6 +2594,25 @@ fn metadata_tokens(
let operation_id = &operation.operation_id; let operation_id = &operation.operation_id;
let method = operation.method.tokens(api_crate); let method = operation.method.tokens(api_crate);
let path = &operation.path; 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) { let request_kind = match operation.request_body.as_ref().map(|body| body.wire_kind) {
Some(RequestWireKind::Json) => quote!(#api_crate::WireKind::Json), Some(RequestWireKind::Json) => quote!(#api_crate::WireKind::Json),
Some(RequestWireKind::Binary) => quote!(#api_crate::WireKind::Binary), Some(RequestWireKind::Binary) => quote!(#api_crate::WireKind::Binary),
@@ -2333,11 +2661,13 @@ fn metadata_tokens(
return None; return None;
} }
let rust_name = &parameter.rust_name; let rust_name = &parameter.rust_name;
let ty = &parameter.ty;
let wire_name = &parameter.wire_name; let wire_name = &parameter.wire_name;
let location = parameter.location.tokens(api_crate); let location = parameter.location.tokens(api_crate);
Some(quote! { Some(quote! {
#api_crate::ParameterMetadata { #api_crate::ParameterMetadata {
rust_name: #rust_name, rust_name: #rust_name,
rust_type: stringify!(#ty),
wire_name: #wire_name, wire_name: #wire_name,
location: #location, location: #location,
} }
@@ -2349,6 +2679,7 @@ fn metadata_tokens(
operation_id: #operation_id, operation_id: #operation_id,
method: #method, method: #method,
path: #path, path: #path,
transport: #transport,
parameters: &[#(#parameters),*], parameters: &[#(#parameters),*],
request_body: #api_crate::BodyMetadata { wire_kind: #request_kind }, request_body: #api_crate::BodyMetadata { wire_kind: #request_kind },
success_responses: &[#(#success_responses),*], success_responses: &[#(#success_responses),*],
+23 -1
View File
@@ -8,9 +8,11 @@
use std::{fmt, str::FromStr}; use std::{fmt, str::FromStr};
use axum::{ use axum::{
Json, Json, Router,
handler::Handler,
http::{HeaderMap, StatusCode}, http::{HeaderMap, StatusCode},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
routing::get,
}; };
use serde::Serialize; use serde::Serialize;
@@ -90,6 +92,26 @@ pub fn status(code: u16) -> StatusCode {
StatusCode::from_u16(code).expect("#[api] validates HTTP status constants") 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<O, H, T, S>(router: Router<S>, handler: H) -> Router<S>
where
O: crate::WebSocketOperation,
H: Handler<T, S>,
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. /// Reexports used by generated router code.
pub mod framework { pub mod framework {
pub use axum::{ pub use axum::{
+58 -1
View File
@@ -59,6 +59,34 @@
//! `openapi = false` explicitly excludes an operation whose wire body cannot yet satisfy the //! `openapi = false` explicitly excludes an operation whose wire body cannot yet satisfy the
//! strict OpenAPI schema boundary; Reqwest and Axum adapters are still generated. //! 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 //! # Generated names
//! //!
//! For `WidgetApi`, the macro emits `WidgetApiMetadata`, which implements [`ApiContract`], //! For `WidgetApi`, the macro emits `WidgetApiMetadata`, which implements [`ApiContract`],
@@ -216,14 +244,27 @@ pub enum WireKind {
Binary, Binary,
} }
/// Stable metadata for one Rust method argument. /// Stable metadata for one operation argument.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct ParameterMetadata { pub struct ParameterMetadata {
pub rust_name: &'static str, pub rust_name: &'static str,
pub rust_type: &'static str,
pub wire_name: &'static str, pub wire_name: &'static str,
pub location: ParameterLocation, 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. /// Stable metadata for a request or response body.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct BodyMetadata { pub struct BodyMetadata {
@@ -253,6 +294,7 @@ pub struct OperationMetadata {
pub operation_id: &'static str, pub operation_id: &'static str,
pub method: HttpMethod, pub method: HttpMethod,
pub path: &'static str, pub path: &'static str,
pub transport: TransportMetadata,
pub parameters: &'static [ParameterMetadata], pub parameters: &'static [ParameterMetadata],
pub request_body: BodyMetadata, pub request_body: BodyMetadata,
/// Every declared successful response, including status-specific body and header shape. /// Every declared successful response, including status-specific body and header shape.
@@ -280,6 +322,21 @@ pub trait Operation {
const METADATA: OperationMetadata; 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. /// 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 /// `error_status` remains the contract's default/documented status. Implementations may return a
+46 -1
View File
@@ -1,7 +1,8 @@
#![allow(async_fn_in_trait)] #![allow(async_fn_in_trait)]
use api_macros::{ use api_macros::{
ApiContract, BinaryBody, HttpMethod, NoBody, Operation, ParameterLocation, WireKind, api, ApiContract, BinaryBody, HttpMethod, NoBody, Operation, ParameterLocation, TransportMetadata,
WebSocketOperation, WireKind, api,
}; };
pub struct CreateWidget; pub struct CreateWidget;
@@ -9,6 +10,24 @@ pub struct Widget;
pub struct Lookup; pub struct Lookup;
pub struct PublicError; pub struct PublicError;
pub struct SearchResult<T>(std::marker::PhantomData<T>); pub struct SearchResult<T>(std::marker::PhantomData<T>);
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] #[api]
pub trait WidgetApi { pub trait WidgetApi {
@@ -106,11 +125,13 @@ fn expansion_exposes_deterministic_metadata_and_type_connections() {
assert_eq!(operations[0], create); assert_eq!(operations[0], create);
assert_eq!(create.method, HttpMethod::Post); assert_eq!(create.method, HttpMethod::Post);
assert_eq!(create.path, "/widgets"); assert_eq!(create.path, "/widgets");
assert_eq!(create.transport, TransportMetadata::Http);
assert_eq!(create.request_body.wire_kind, WireKind::Json); assert_eq!(create.request_body.wire_kind, WireKind::Json);
assert_eq!(create.success_responses[0].status, 201); assert_eq!(create.success_responses[0].status, 201);
assert_eq!(create.success_responses[0].body.wire_kind, WireKind::Json); assert_eq!(create.success_responses[0].body.wire_kind, WireKind::Json);
assert_eq!(create.error_responses[0].status, 422); assert_eq!(create.error_responses[0].status, 422);
assert_eq!(create.parameters[0].location, ParameterLocation::Body); 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].location, ParameterLocation::Header);
assert_eq!(create.parameters[1].wire_name, "x-request-id"); 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<O: Operation<ResponseBody = NoBody, ErrorBody = NoBody>>() {} fn assert_empty_response<O: Operation<ResponseBody = NoBody, ErrorBody = NoBody>>() {}
assert_empty_request::<widget_api_operations::Lookup>(); assert_empty_request::<widget_api_operations::Lookup>();
assert_empty_response::<widget_api_operations::Delete>(); assert_empty_response::<widget_api_operations::Delete>();
fn assert_websocket_types<
O: WebSocketOperation<
PathParameters = (u64,),
ClientToServerFrame = ClientFrame,
ServerToClientFrame = ServerFrame,
>,
>() {
}
assert_websocket_types::<mixed_transport_api_operations::WidgetEvents>();
let websocket = <mixed_transport_api_operations::WidgetEvents as WebSocketOperation>::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",
}
);
} }
+54
View File
@@ -8,6 +8,26 @@ use std::{
use api_macros::{api, reqwest as api_reqwest}; use api_macros::{api, reqwest as api_reqwest};
use serde::{Deserialize, Serialize}; 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)] #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct CreateWidget { pub struct CreateWidget {
name: String, 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<u64>,
) -> 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::<web_socket_only_api_operations::Events, _, _, _>(
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] #[tokio::test]
async fn generated_client_and_router_follow_the_normalized_contract() { async fn generated_client_and_router_follow_the_normalized_contract() {
let app = WidgetApiAxum::router(WidgetService).merge( let app = WidgetApiAxum::router(WidgetService).merge(
+17
View File
@@ -81,6 +81,9 @@ impl OpenApiSchema for PublicError {}
type OptionalRequestId = Option<String>; type OptionalRequestId = Option<String>;
pub struct ClientFrame;
pub struct ServerFrame;
#[api(openapi)] #[api(openapi)]
pub trait FixtureApi { pub trait FixtureApi {
#[post( #[post(
@@ -113,6 +116,16 @@ pub trait FixtureApi {
] ]
)] )]
async fn conditional(&self) -> fixture_api_responses::Conditional; 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 { fn document() -> api_macros::openapi::OpenApiDocument {
@@ -188,6 +201,10 @@ fn collect_schema_references<'a>(value: &'a Value, references: &mut Vec<&'a str>
#[test] #[test]
fn operations_and_components_preserve_the_wire_contract() { fn operations_and_components_preserve_the_wire_contract() {
let value = document().as_value().clone(); 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"]; let create = &value["paths"]["/widgets/{widget_id}"]["post"];
assert_eq!(create["operationId"], "widgets.create"); assert_eq!(create["operationId"], "widgets.create");
assert_eq!(create["security"], json!([{ "bearerAuth": [] }])); assert_eq!(create["security"], json!([{ "bearerAuth": [] }]));
@@ -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() {}
@@ -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]
| ^^^^^^^^^
@@ -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() {}
@@ -0,0 +1,5 @@
error: duplicate route for GET `/widgets/{widget_id}/events`
--> tests/ui/duplicate_websocket_route.rs:18:10
|
18 | type WidgetEvents;
| ^^^^^^^^^^^^
@@ -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() {}
@@ -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
| ^^^^^^^^^^^^^^^^
@@ -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() {}
@@ -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
| ^^^^^^
@@ -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() {}
@@ -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
| ^^^^
@@ -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() {}
@@ -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),
| ^^^^^^^^^^^^^^
@@ -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() {}
@@ -0,0 +1,5 @@
error: WebSocket operations require `method = GET`
--> tests/ui/invalid_websocket_method.rs:9:18
|
9 | method = POST,
| ^^^^
@@ -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() {}
@@ -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
| ^^^^^^
@@ -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() {}
@@ -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
| ^^^^^^^^
@@ -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() {}
@@ -0,0 +1,5 @@
error: WebSocket operations require `server_to_client = FrameType`
--> tests/ui/missing_websocket_frame.rs:8:9
|
8 | "/events",
| ^^^^^^^^^
@@ -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() {}
@@ -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",
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@@ -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() {}
@@ -0,0 +1,5 @@
error: path parameter(s) have no matching placeholder: unused
--> tests/ui/websocket_unused_path_parameter.rs:8:9
|
8 | "/events",
| ^^^^^^^^^
+276 -6
View File
@@ -9,7 +9,7 @@ use std::collections::BTreeMap;
use api_macros::api; use api_macros::api;
pub use api_macros::axum as server_support; pub use api_macros::axum as server_support;
pub use api_macros::reqwest as client_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; pub mod repository_openapi_typescript;
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -2885,10 +2885,147 @@ pub trait ServerApi {
#[path] objective_id: String, #[path] objective_id: String,
#[path] ticket_id: String, #[path] ticket_id: String,
) -> Result<ObjectiveDetail, RepositoryApiError>; ) -> Result<ObjectiveDetail, RepositoryApiError>;
#[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. /// Stable fingerprint for one complete operation declaration.
pub fn canonical_openapi_source_digest() -> Result<String, api_macros::openapi::OpenApiError> { ///
/// 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<String, api_macros::openapi::OpenApiError> {
const NORMALIZED_DIGEST: &str = const NORMALIZED_DIGEST: &str =
"sha256:0000000000000000000000000000000000000000000000000000000000000000"; "sha256:0000000000000000000000000000000000000000000000000000000000000000";
let normalized = server_api_openapi(api_macros::openapi::OpenApiInfo { let normalized = server_api_openapi(api_macros::openapi::OpenApiInfo {
@@ -2897,7 +3034,15 @@ pub fn canonical_openapi_source_digest() -> Result<String, api_macros::openapi::
source_digest: NORMALIZED_DIGEST, source_digest: NORMALIZED_DIGEST,
})? })?
.to_json()?; .to_json()?;
let digest = Sha256::digest(normalized.as_bytes()); let mut hasher = Sha256::new();
hasher.update((normalized.len() as u64).to_be_bytes());
hasher.update(normalized.as_bytes());
for operation in ServerApiMetadata::OPERATIONS {
let fingerprint = operation_fingerprint(operation);
hasher.update((fingerprint.len() as u64).to_be_bytes());
hasher.update(fingerprint.as_bytes());
}
let digest = hasher.finalize();
let mut encoded = String::with_capacity("sha256:".len() + digest.len() * 2); let mut encoded = String::with_capacity("sha256:".len() + digest.len() * 2);
encoded.push_str("sha256:"); encoded.push_str("sha256:");
for byte in digest { for byte in digest {
@@ -2910,7 +3055,7 @@ pub fn canonical_openapi_source_digest() -> Result<String, api_macros::openapi::
/// Build the deployment-independent canonical ServerApi OpenAPI document. /// Build the deployment-independent canonical ServerApi OpenAPI document.
pub fn canonical_openapi_document() pub fn canonical_openapi_document()
-> Result<api_macros::openapi::OpenApiDocument, api_macros::openapi::OpenApiError> { -> Result<api_macros::openapi::OpenApiDocument, api_macros::openapi::OpenApiError> {
let source_digest = canonical_openapi_source_digest()?; let source_digest = canonical_contract_source_digest()?;
server_api_openapi(api_macros::openapi::OpenApiInfo { server_api_openapi(api_macros::openapi::OpenApiInfo {
title: "Yoi Server API", title: "Yoi Server API",
version: env!("CARGO_PKG_VERSION"), version: env!("CARGO_PKG_VERSION"),
@@ -9124,6 +9269,128 @@ mod skill_typescript_tests {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn websocket_operations_preserve_routes_and_directional_frame_authorities() {
use api_macros::{TransportMetadata, WebSocketOperation};
fn workspace_protocol<O>()
where
O: WebSocketOperation<
PathParameters = (String,),
ClientToServerFrame = protocol::subscription::SubscriptionFrame,
ServerToClientFrame = protocol::subscription::SubscriptionFrame,
>,
{
}
fn external_provider<O>()
where
O: WebSocketOperation<
PathParameters = (String, String),
ClientToServerFrame = workdir::external::ExternalWorkdirProviderFrame,
ServerToClientFrame = workdir::external::ExternalWorkdirServerFrame,
>,
{
}
fn direct_worker<O, P>()
where
O: WebSocketOperation<
PathParameters = P,
ClientToServerFrame = protocol::Method,
ServerToClientFrame = protocol::Event,
>,
{
}
workspace_protocol::<server_api_operations::WorkspaceProtocolWebSocket>();
external_provider::<server_api_operations::ExternalWorkdirProviderWebSocket>();
direct_worker::<server_api_operations::WorkerProtocolWebSocketAlias, (String, String)>();
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] #[test]
fn runtime_config_response_round_trips_the_complete_config_bundle_shape() { fn runtime_config_response_round_trips_the_complete_config_bundle_shape() {
let fixture = serde_json::json!({ 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::<Vec<_>>();
assert_eq!( assert_eq!(
documented.len() + SIGNED_INTERNAL.len() + OTHER_OPENAPI_EXCLUDED.len(), documented.len() + SIGNED_INTERNAL.len() + OTHER_OPENAPI_EXCLUDED.len(),
operations.len(), operations.len(),
+28 -22
View File
@@ -8311,28 +8311,34 @@ impl server_api::ServerApi for ServerApiContractService {
fn build_inner_router(api: WorkspaceApi) -> Router { fn build_inner_router(api: WorkspaceApi) -> Router {
let contract_service = ServerApiContractService::Workspace(api.clone()); let contract_service = ServerApiContractService::Workspace(api.clone());
let auth = generated_auth_contract_router(contract_service.clone()); let auth = generated_auth_contract_router(contract_service.clone());
let workspace = Router::new() let workspace = Router::new();
// WebSocket provider transport is intentionally outside the generated let workspace = server_api::server_support::websocket_route::<
// JSON REST contract. server_api::server_api_operations::ExternalWorkdirProviderWebSocket,
.route( _,
"/api/w/{workspace_id}/external-workdir-grants/{grant_id}/provider", _,
get(scoped_external_workdir_provider_ws), _,
) >(workspace, scoped_external_workdir_provider_ws);
.route( let workspace = server_api::server_support::websocket_route::<
"/api/w/{workspace_id}/protocol/ws", server_api::server_api_operations::WorkspaceProtocolWebSocket,
get(scoped_workspace_protocol_ws), _,
) _,
.route( _,
"/api/runtimes/{runtime_id}/workers/{worker_id}/protocol/ws", >(workspace, scoped_workspace_protocol_ws);
get(worker_protocol_ws), let workspace = server_api::server_support::websocket_route::<
) server_api::server_api_operations::WorkerProtocolWebSocketAlias,
.route( _,
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/protocol/ws", _,
get(scoped_worker_protocol_ws), _,
) >(workspace, worker_protocol_ws);
.fallback(get(static_or_spa_fallback)) let workspace = server_api::server_support::websocket_route::<
.with_state(api) server_api::server_api_operations::WorkspaceWorkerProtocolWebSocket,
.merge(generated_workspace_contract_router(contract_service)); _,
_,
_,
>(workspace, scoped_worker_protocol_ws)
.fallback(get(static_or_spa_fallback))
.with_state(api)
.merge(generated_workspace_contract_router(contract_service));
auth.merge(workspace) auth.merge(workspace)
.layer(middleware::from_fn(log_failed_api_response)) .layer(middleware::from_fn(log_failed_api_response))
} }
+1 -1
View File
@@ -13893,7 +13893,7 @@
"info": { "info": {
"title": "Yoi Server API", "title": "Yoi Server API",
"version": "0.1.0", "version": "0.1.0",
"x-yoi-source-digest": "sha256:2463eb461342aceb84c87e7aa6bdcf73e28e3866349a555bd4a0d795db671a3f" "x-yoi-source-digest": "sha256:7a9d11f50cc41eca55a6dded4b8f55aa6d23b1beb2dc69b0a9b6c8349efc894f"
}, },
"openapi": "3.1.0", "openapi": "3.1.0",
"paths": { "paths": {
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit. // @generated by yoi-companion-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // 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 // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/companion-api.ts // Output: web/workspace/src/lib/generated/companion-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit. // @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // 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 // 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 // Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-openapi-typescript@1. Do not edit. // @generated by yoi-runtime-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // 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 // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts // Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // 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 // 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 // Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-workdir-openapi-typescript@1. Do not edit. // @generated by yoi-workdir-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // 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 // Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/workdir-api.ts // Output: web/workspace/src/lib/generated/workdir-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-worker-openapi-typescript@1. Do not edit. // @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // 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 // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts // 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 // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check