feat(api): declare typed WebSocket operations
This commit is contained in:
@@ -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<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 {
|
||||
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<Parameter>,
|
||||
client_to_server_frame: Option<Type>,
|
||||
server_to_client_frame: Option<Type>,
|
||||
request_body: Option<RequestBody>,
|
||||
response_body: Option<Type>,
|
||||
success_responses: Vec<SuccessResponse>,
|
||||
@@ -434,24 +535,36 @@ fn normalize_api(
|
||||
let mut routes = BTreeMap::<(Method, String), Span>::new();
|
||||
let mut marker_names = BTreeMap::<String, (String, Span)>::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<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> {
|
||||
validate_signature(method)?;
|
||||
|
||||
@@ -912,9 +1173,12 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result<Operation> {
|
||||
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<proc_macro2::TokenStream> {
|
||||
.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<proc_macro2::TokenStream> {
|
||||
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<proc_macro2::TokenStream> {
|
||||
}
|
||||
})
|
||||
.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<proc_macro2::TokenStream> {
|
||||
}
|
||||
|
||||
#(#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),*],
|
||||
|
||||
@@ -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<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.
|
||||
pub mod framework {
|
||||
pub use axum::{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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]
|
||||
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<O: Operation<ResponseBody = NoBody, ErrorBody = NoBody>>() {}
|
||||
assert_empty_request::<widget_api_operations::Lookup>();
|
||||
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",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<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]
|
||||
async fn generated_client_and_router_follow_the_normalized_contract() {
|
||||
let app = WidgetApiAxum::router(WidgetService).merge(
|
||||
|
||||
@@ -81,6 +81,9 @@ impl OpenApiSchema for PublicError {}
|
||||
|
||||
type OptionalRequestId = Option<String>;
|
||||
|
||||
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": [] }]));
|
||||
|
||||
@@ -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",
|
||||
| ^^^^^^^^^
|
||||
@@ -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<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.
|
||||
pub fn canonical_openapi_source_digest() -> Result<String, api_macros::openapi::OpenApiError> {
|
||||
/// 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<String, api_macros::openapi::OpenApiError> {
|
||||
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<String, api_macros::openapi::
|
||||
source_digest: NORMALIZED_DIGEST,
|
||||
})?
|
||||
.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);
|
||||
encoded.push_str("sha256:");
|
||||
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.
|
||||
pub fn canonical_openapi_document()
|
||||
-> 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 {
|
||||
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<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]
|
||||
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::<Vec<_>>();
|
||||
assert_eq!(
|
||||
documented.len() + SIGNED_INTERNAL.len() + OTHER_OPENAPI_EXCLUDED.len(),
|
||||
operations.len(),
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user