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
+23 -1
View File
@@ -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::{
+58 -1
View File
@@ -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
+46 -1
View File
@@ -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",
}
);
}
+54
View File
@@ -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(
+17
View File
@@ -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",
| ^^^^^^^^^