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()
}
#[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| &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
.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 = &parameter.rust_name;
let ty = &parameter.ty;
let wire_name = &parameter.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),*],
+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",
| ^^^^^^^^^
+276 -6
View File
@@ -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(),
+28 -22
View File
@@ -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))
}
+1 -1
View File
@@ -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