feat(api): declare typed WebSocket operations
This commit is contained in:
@@ -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",
|
||||
| ^^^^^^^^^
|
||||
Reference in New Issue
Block a user