feat(api): declare typed WebSocket operations
This commit is contained in:
@@ -26,6 +26,12 @@ pub fn api(attr: TokenStream, item: TokenStream) -> TokenStream {
|
|||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||||
|
enum Transport {
|
||||||
|
Http,
|
||||||
|
WebSocket,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||||
enum Method {
|
enum Method {
|
||||||
Get,
|
Get,
|
||||||
@@ -114,6 +120,98 @@ struct RouteArgs {
|
|||||||
openapi: Option<syn::LitBool>,
|
openapi: Option<syn::LitBool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct WebSocketArgs {
|
||||||
|
path: LitStr,
|
||||||
|
operation_id: Option<LitStr>,
|
||||||
|
method: Option<Ident>,
|
||||||
|
client_to_server: Option<Type>,
|
||||||
|
server_to_client: Option<Type>,
|
||||||
|
path_parameters: Vec<(Ident, Type)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Parse for WebSocketArgs {
|
||||||
|
fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
|
||||||
|
let path = input.parse()?;
|
||||||
|
let mut result = Self {
|
||||||
|
path,
|
||||||
|
operation_id: None,
|
||||||
|
method: None,
|
||||||
|
client_to_server: None,
|
||||||
|
server_to_client: None,
|
||||||
|
path_parameters: Vec::new(),
|
||||||
|
};
|
||||||
|
let mut saw_path_parameters = false;
|
||||||
|
while !input.is_empty() {
|
||||||
|
input.parse::<Token![,]>()?;
|
||||||
|
if input.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let key: Ident = input.parse()?;
|
||||||
|
input.parse::<Token![=]>()?;
|
||||||
|
match key.to_string().as_str() {
|
||||||
|
"operation_id" => set_once(
|
||||||
|
&mut result.operation_id,
|
||||||
|
input.parse::<LitStr>()?,
|
||||||
|
&key,
|
||||||
|
"operation_id",
|
||||||
|
)?,
|
||||||
|
"method" => set_once(&mut result.method, input.parse::<Ident>()?, &key, "method")?,
|
||||||
|
"client_to_server" => set_once(
|
||||||
|
&mut result.client_to_server,
|
||||||
|
input.parse::<Type>()?,
|
||||||
|
&key,
|
||||||
|
"client_to_server",
|
||||||
|
)?,
|
||||||
|
"server_to_client" => set_once(
|
||||||
|
&mut result.server_to_client,
|
||||||
|
input.parse::<Type>()?,
|
||||||
|
&key,
|
||||||
|
"server_to_client",
|
||||||
|
)?,
|
||||||
|
"path_parameters" => {
|
||||||
|
if saw_path_parameters {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
key.span(),
|
||||||
|
"duplicate `path_parameters` WebSocket option",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
saw_path_parameters = true;
|
||||||
|
let content;
|
||||||
|
syn::bracketed!(content in input);
|
||||||
|
while !content.is_empty() {
|
||||||
|
let name = content.parse::<Ident>()?;
|
||||||
|
content.parse::<Token![:]>()?;
|
||||||
|
let ty = content.parse::<Type>()?;
|
||||||
|
result.path_parameters.push((name, ty));
|
||||||
|
if !content.is_empty() {
|
||||||
|
content.parse::<Token![,]>()?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"body" | "binary" => {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
key.span(),
|
||||||
|
"WebSocket operations cannot declare JSON or binary request bodies",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
"status" | "alternate_status" | "responses" | "response" => {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
key.span(),
|
||||||
|
"WebSocket operations cannot declare unary success responses or statuses",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
key.span(),
|
||||||
|
"unsupported WebSocket option; expected operation_id, method, client_to_server, server_to_client, or path_parameters",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct DeclaredResponseHeader {
|
struct DeclaredResponseHeader {
|
||||||
name: LitStr,
|
name: LitStr,
|
||||||
field_ident: Ident,
|
field_ident: Ident,
|
||||||
@@ -381,9 +479,12 @@ struct Operation {
|
|||||||
method_ident: Ident,
|
method_ident: Ident,
|
||||||
marker_ident: Ident,
|
marker_ident: Ident,
|
||||||
operation_id: String,
|
operation_id: String,
|
||||||
|
transport: Transport,
|
||||||
method: Method,
|
method: Method,
|
||||||
path: String,
|
path: String,
|
||||||
parameters: Vec<Parameter>,
|
parameters: Vec<Parameter>,
|
||||||
|
client_to_server_frame: Option<Type>,
|
||||||
|
server_to_client_frame: Option<Type>,
|
||||||
request_body: Option<RequestBody>,
|
request_body: Option<RequestBody>,
|
||||||
response_body: Option<Type>,
|
response_body: Option<Type>,
|
||||||
success_responses: Vec<SuccessResponse>,
|
success_responses: Vec<SuccessResponse>,
|
||||||
@@ -434,24 +535,36 @@ fn normalize_api(
|
|||||||
let mut routes = BTreeMap::<(Method, String), Span>::new();
|
let mut routes = BTreeMap::<(Method, String), Span>::new();
|
||||||
let mut marker_names = BTreeMap::<String, (String, Span)>::new();
|
let mut marker_names = BTreeMap::<String, (String, Span)>::new();
|
||||||
|
|
||||||
for trait_item in &mut item.items {
|
let mut retained_items = Vec::new();
|
||||||
let TraitItem::Fn(method) = trait_item else {
|
for mut trait_item in std::mem::take(&mut item.items) {
|
||||||
return Err(syn::Error::new_spanned(
|
let operation = match &mut trait_item {
|
||||||
trait_item,
|
TraitItem::Fn(method) => {
|
||||||
"an #[api] trait may contain methods only",
|
let operation = normalize_operation(method)?;
|
||||||
));
|
retained_items.push(trait_item);
|
||||||
|
operation
|
||||||
|
}
|
||||||
|
TraitItem::Type(declaration) if has_websocket_attribute(&declaration.attrs) => {
|
||||||
|
normalize_websocket_operation(declaration)?
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(syn::Error::new_spanned(
|
||||||
|
trait_item,
|
||||||
|
"an #[api] trait may contain HTTP methods and #[websocket] operation declarations only",
|
||||||
|
));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let operation = normalize_operation(method)?;
|
let source_ident = operation.method_ident.clone();
|
||||||
|
let source_name = ident_text(&source_ident);
|
||||||
|
let source_span = source_ident.span();
|
||||||
|
|
||||||
let marker_name = operation.marker_ident.to_string();
|
let marker_name = operation.marker_ident.to_string();
|
||||||
if let Some((first_method, first_span)) = marker_names.insert(
|
if let Some((first_operation, first_span)) =
|
||||||
marker_name.clone(),
|
marker_names.insert(marker_name.clone(), (source_name, source_span))
|
||||||
(ident_text(&method.sig.ident), method.sig.ident.span()),
|
{
|
||||||
) {
|
|
||||||
let mut error = syn::Error::new(
|
let mut error = syn::Error::new(
|
||||||
method.sig.ident.span(),
|
source_span,
|
||||||
format!(
|
format!(
|
||||||
"generated operation marker `{marker_name}` collides with method `{first_method}`"
|
"generated operation marker `{marker_name}` collides with method `{first_operation}`"
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
error.combine(syn::Error::new(
|
error.combine(syn::Error::new(
|
||||||
@@ -462,23 +575,20 @@ fn normalize_api(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if operation_ids
|
if operation_ids
|
||||||
.insert(operation.operation_id.clone(), method.sig.ident.span())
|
.insert(operation.operation_id.clone(), source_span)
|
||||||
.is_some()
|
.is_some()
|
||||||
{
|
{
|
||||||
return Err(syn::Error::new(
|
return Err(syn::Error::new(
|
||||||
method.sig.ident.span(),
|
source_span,
|
||||||
format!("duplicate operation ID `{}`", operation.operation_id),
|
format!("duplicate operation ID `{}`", operation.operation_id),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if routes
|
if routes
|
||||||
.insert(
|
.insert((operation.method, operation.path.clone()), source_span)
|
||||||
(operation.method, operation.path.clone()),
|
|
||||||
method.sig.ident.span(),
|
|
||||||
)
|
|
||||||
.is_some()
|
.is_some()
|
||||||
{
|
{
|
||||||
return Err(syn::Error::new(
|
return Err(syn::Error::new(
|
||||||
method.sig.ident.span(),
|
source_span,
|
||||||
format!(
|
format!(
|
||||||
"duplicate route for {} `{}`",
|
"duplicate route for {} `{}`",
|
||||||
method_name(operation.method),
|
method_name(operation.method),
|
||||||
@@ -488,6 +598,7 @@ fn normalize_api(
|
|||||||
}
|
}
|
||||||
operations.push(operation);
|
operations.push(operation);
|
||||||
}
|
}
|
||||||
|
item.items = retained_items;
|
||||||
|
|
||||||
if operations.is_empty() {
|
if operations.is_empty() {
|
||||||
return Err(syn::Error::new_spanned(
|
return Err(syn::Error::new_spanned(
|
||||||
@@ -528,6 +639,156 @@ fn make_service_futures_send(item: &mut ItemTrait) -> syn::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn has_websocket_attribute(attrs: &[Attribute]) -> bool {
|
||||||
|
attrs.iter().any(|attr| attr.path().is_ident("websocket"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_websocket_operation(declaration: &mut syn::TraitItemType) -> syn::Result<Operation> {
|
||||||
|
if !declaration.generics.params.is_empty()
|
||||||
|
|| declaration.generics.where_clause.is_some()
|
||||||
|
|| !declaration.bounds.is_empty()
|
||||||
|
|| declaration.default.is_some()
|
||||||
|
{
|
||||||
|
return Err(syn::Error::new_spanned(
|
||||||
|
declaration,
|
||||||
|
"a #[websocket] declaration cannot have generics, bounds, or a default type",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut route = None;
|
||||||
|
for attr in &declaration.attrs {
|
||||||
|
if attr.path().is_ident("websocket") {
|
||||||
|
if route.is_some() {
|
||||||
|
return Err(syn::Error::new_spanned(
|
||||||
|
attr,
|
||||||
|
"a WebSocket operation must have exactly one #[websocket] attribute",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
route = Some(attr.parse_args::<WebSocketArgs>()?);
|
||||||
|
} else if !attr.path().is_ident("doc") {
|
||||||
|
return Err(syn::Error::new_spanned(
|
||||||
|
attr,
|
||||||
|
"a #[websocket] declaration supports only documentation attributes",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let route = route.expect("caller checked for a WebSocket attribute");
|
||||||
|
let method = route.method.ok_or_else(|| {
|
||||||
|
syn::Error::new_spanned(
|
||||||
|
&route.path,
|
||||||
|
"WebSocket operations require explicit `method = GET`",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if method != "GET" {
|
||||||
|
return Err(syn::Error::new_spanned(
|
||||||
|
method,
|
||||||
|
"WebSocket operations require `method = GET`",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let client_to_server_frame = route.client_to_server.ok_or_else(|| {
|
||||||
|
syn::Error::new_spanned(
|
||||||
|
&route.path,
|
||||||
|
"WebSocket operations require `client_to_server = FrameType`",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let server_to_client_frame = route.server_to_client.ok_or_else(|| {
|
||||||
|
syn::Error::new_spanned(
|
||||||
|
&route.path,
|
||||||
|
"WebSocket operations require `server_to_client = FrameType`",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
validate_frame_type(&client_to_server_frame)?;
|
||||||
|
validate_frame_type(&server_to_client_frame)?;
|
||||||
|
|
||||||
|
let path = route.path.value();
|
||||||
|
let placeholders = parse_path_template(&route.path)?;
|
||||||
|
let mut path_parameters = BTreeSet::new();
|
||||||
|
let mut parameters = Vec::new();
|
||||||
|
for (ident, ty) in route.path_parameters {
|
||||||
|
let rust_name = ident_text(&ident);
|
||||||
|
if !path_parameters.insert(rust_name.clone()) {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
ident.span(),
|
||||||
|
format!("duplicate path parameter `{rust_name}`"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
parameters.push(Parameter {
|
||||||
|
rust_ident: ident,
|
||||||
|
rust_name: rust_name.clone(),
|
||||||
|
wire_name: rust_name,
|
||||||
|
location: Location::Path,
|
||||||
|
ty,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let missing: Vec<_> = placeholders.difference(&path_parameters).cloned().collect();
|
||||||
|
if !missing.is_empty() {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
route.path.span(),
|
||||||
|
format!(
|
||||||
|
"path placeholder(s) have no matching path parameter: {}",
|
||||||
|
missing.join(", ")
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let extra: Vec<_> = path_parameters.difference(&placeholders).cloned().collect();
|
||||||
|
if !extra.is_empty() {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
route.path.span(),
|
||||||
|
format!(
|
||||||
|
"path parameter(s) have no matching placeholder: {}",
|
||||||
|
extra.join(", ")
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let operation_id = route
|
||||||
|
.operation_id
|
||||||
|
.as_ref()
|
||||||
|
.map(LitStr::value)
|
||||||
|
.unwrap_or_else(|| to_snake_case(&declaration.ident));
|
||||||
|
validate_operation_id(
|
||||||
|
&operation_id,
|
||||||
|
route.operation_id.as_ref().unwrap_or(&route.path),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(Operation {
|
||||||
|
method_ident: declaration.ident.clone(),
|
||||||
|
marker_ident: declaration.ident.clone(),
|
||||||
|
operation_id,
|
||||||
|
transport: Transport::WebSocket,
|
||||||
|
method: Method::Get,
|
||||||
|
path,
|
||||||
|
parameters,
|
||||||
|
client_to_server_frame: Some(client_to_server_frame),
|
||||||
|
server_to_client_frame: Some(server_to_client_frame),
|
||||||
|
request_body: None,
|
||||||
|
response_body: None,
|
||||||
|
success_responses: Vec::new(),
|
||||||
|
declared_responses: false,
|
||||||
|
error_body: None,
|
||||||
|
fallible: false,
|
||||||
|
response_status: 101,
|
||||||
|
alternate_status: None,
|
||||||
|
error_status: None,
|
||||||
|
additional_error_statuses: Vec::new(),
|
||||||
|
bearer_auth: false,
|
||||||
|
browser_auth: false,
|
||||||
|
normalize_body_errors: false,
|
||||||
|
openapi_skip: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_frame_type(ty: &Type) -> syn::Result<()> {
|
||||||
|
if matches!(ty, Type::Path(path) if path.qself.is_none()) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(syn::Error::new_spanned(
|
||||||
|
ty,
|
||||||
|
"WebSocket frame authorities must use direct named Rust types",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn normalize_operation(method: &mut TraitItemFn) -> syn::Result<Operation> {
|
fn normalize_operation(method: &mut TraitItemFn) -> syn::Result<Operation> {
|
||||||
validate_signature(method)?;
|
validate_signature(method)?;
|
||||||
|
|
||||||
@@ -912,9 +1173,12 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result<Operation> {
|
|||||||
marker_ident: operation_marker_ident(&method.sig.ident),
|
marker_ident: operation_marker_ident(&method.sig.ident),
|
||||||
method_ident: method.sig.ident.clone(),
|
method_ident: method.sig.ident.clone(),
|
||||||
operation_id,
|
operation_id,
|
||||||
|
transport: Transport::Http,
|
||||||
method: http_method,
|
method: http_method,
|
||||||
path,
|
path,
|
||||||
parameters,
|
parameters,
|
||||||
|
client_to_server_frame: None,
|
||||||
|
server_to_client_frame: None,
|
||||||
request_body: body_type,
|
request_body: body_type,
|
||||||
response_body,
|
response_body,
|
||||||
success_responses,
|
success_responses,
|
||||||
@@ -1379,7 +1643,11 @@ fn reqwest_adapter_tokens(
|
|||||||
to_snake_case(trait_ident),
|
to_snake_case(trait_ident),
|
||||||
span = trait_ident.span()
|
span = trait_ident.span()
|
||||||
);
|
);
|
||||||
let methods = api.operations.iter().map(|operation| {
|
let methods = api
|
||||||
|
.operations
|
||||||
|
.iter()
|
||||||
|
.filter(|operation| matches!(operation.transport, Transport::Http))
|
||||||
|
.map(|operation| {
|
||||||
let method_ident = &operation.method_ident;
|
let method_ident = &operation.method_ident;
|
||||||
let arguments = operation.parameters.iter().filter_map(|parameter| {
|
let arguments = operation.parameters.iter().filter_map(|parameter| {
|
||||||
if matches!(parameter.location, Location::Extension) {
|
if matches!(parameter.location, Location::Extension) {
|
||||||
@@ -1716,7 +1984,11 @@ fn axum_adapter_tokens(
|
|||||||
to_snake_case(trait_ident),
|
to_snake_case(trait_ident),
|
||||||
span = trait_ident.span()
|
span = trait_ident.span()
|
||||||
);
|
);
|
||||||
let route_functions: Vec<_> = api.operations.iter().map(|operation| {
|
let route_functions: Vec<_> = api
|
||||||
|
.operations
|
||||||
|
.iter()
|
||||||
|
.filter(|operation| matches!(operation.transport, Transport::Http))
|
||||||
|
.map(|operation| {
|
||||||
let method_ident = &operation.method_ident;
|
let method_ident = &operation.method_ident;
|
||||||
let handler_ident = format_ident!("{}_handler", ident_text(method_ident));
|
let handler_ident = format_ident!("{}_handler", ident_text(method_ident));
|
||||||
let route_path = &operation.path;
|
let route_path = &operation.path;
|
||||||
@@ -1959,10 +2231,14 @@ fn axum_adapter_tokens(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}).collect();
|
}).collect();
|
||||||
let operation_merges = api.operations.iter().map(|operation| {
|
let operation_merges = api
|
||||||
let method_ident = &operation.method_ident;
|
.operations
|
||||||
quote!(__router = __router.merge(#module_ident::#method_ident(__service.clone()));)
|
.iter()
|
||||||
});
|
.filter(|operation| matches!(operation.transport, Transport::Http))
|
||||||
|
.map(|operation| {
|
||||||
|
let method_ident = &operation.method_ident;
|
||||||
|
quote!(__router = __router.merge(#module_ident::#method_ident(__service.clone()));)
|
||||||
|
});
|
||||||
|
|
||||||
quote! {
|
quote! {
|
||||||
#[doc = concat!("Per-operation Axum routers for [`", stringify!(#trait_ident), "`].")]
|
#[doc = concat!("Per-operation Axum routers for [`", stringify!(#trait_ident), "`].")]
|
||||||
@@ -2005,7 +2281,9 @@ fn openapi_adapter_tokens(
|
|||||||
let operations = api
|
let operations = api
|
||||||
.operations
|
.operations
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|operation| !operation.openapi_skip)
|
.filter(|operation| {
|
||||||
|
matches!(operation.transport, Transport::Http) && !operation.openapi_skip
|
||||||
|
})
|
||||||
.map(|operation| {
|
.map(|operation| {
|
||||||
let method = method_name(operation.method);
|
let method = method_name(operation.method);
|
||||||
let path = &operation.path;
|
let path = &operation.path;
|
||||||
@@ -2188,9 +2466,9 @@ fn expand_api(api: ApiDefinition) -> syn::Result<proc_macro2::TokenStream> {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|operation| {
|
.map(|operation| {
|
||||||
let marker_ident = &operation.marker_ident;
|
let marker_ident = &operation.marker_ident;
|
||||||
let method_ident = &operation.method_ident;
|
let declaration_ident = &operation.method_ident;
|
||||||
quote! {
|
quote! {
|
||||||
#[doc = concat!("Typed operation marker for Rust method `", stringify!(#method_ident), "`.")]
|
#[doc = concat!("Typed operation marker for contract declaration `", stringify!(#declaration_ident), "`.")]
|
||||||
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
|
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
|
||||||
pub struct #marker_ident;
|
pub struct #marker_ident;
|
||||||
}
|
}
|
||||||
@@ -2199,6 +2477,7 @@ fn expand_api(api: ApiDefinition) -> syn::Result<proc_macro2::TokenStream> {
|
|||||||
let operation_impls: Vec<_> = api
|
let operation_impls: Vec<_> = api
|
||||||
.operations
|
.operations
|
||||||
.iter()
|
.iter()
|
||||||
|
.filter(|operation| matches!(operation.transport, Transport::Http))
|
||||||
.map(|operation| {
|
.map(|operation| {
|
||||||
let marker_ident = &operation.marker_ident;
|
let marker_ident = &operation.marker_ident;
|
||||||
let metadata = metadata_tokens(operation, &api_crate);
|
let metadata = metadata_tokens(operation, &api_crate);
|
||||||
@@ -2239,6 +2518,35 @@ fn expand_api(api: ApiDefinition) -> syn::Result<proc_macro2::TokenStream> {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
let websocket_operation_impls: Vec<_> = api
|
||||||
|
.operations
|
||||||
|
.iter()
|
||||||
|
.filter(|operation| matches!(operation.transport, Transport::WebSocket))
|
||||||
|
.map(|operation| {
|
||||||
|
let marker_ident = &operation.marker_ident;
|
||||||
|
let metadata = metadata_tokens(operation, &api_crate);
|
||||||
|
let path_types = ordered_path_parameters(operation)
|
||||||
|
.into_iter()
|
||||||
|
.map(|parameter| ¶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
|
let inventory: Vec<_> = api
|
||||||
.operations
|
.operations
|
||||||
.iter()
|
.iter()
|
||||||
@@ -2271,6 +2579,7 @@ fn expand_api(api: ApiDefinition) -> syn::Result<proc_macro2::TokenStream> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#(#operation_impls)*
|
#(#operation_impls)*
|
||||||
|
#(#websocket_operation_impls)*
|
||||||
|
|
||||||
#reqwest_adapter
|
#reqwest_adapter
|
||||||
#axum_adapter
|
#axum_adapter
|
||||||
@@ -2285,6 +2594,25 @@ fn metadata_tokens(
|
|||||||
let operation_id = &operation.operation_id;
|
let operation_id = &operation.operation_id;
|
||||||
let method = operation.method.tokens(api_crate);
|
let method = operation.method.tokens(api_crate);
|
||||||
let path = &operation.path;
|
let path = &operation.path;
|
||||||
|
let transport = match operation.transport {
|
||||||
|
Transport::Http => quote!(#api_crate::TransportMetadata::Http),
|
||||||
|
Transport::WebSocket => {
|
||||||
|
let client_to_server = operation
|
||||||
|
.client_to_server_frame
|
||||||
|
.as_ref()
|
||||||
|
.expect("WebSocket operation has a client frame");
|
||||||
|
let server_to_client = operation
|
||||||
|
.server_to_client_frame
|
||||||
|
.as_ref()
|
||||||
|
.expect("WebSocket operation has a server frame");
|
||||||
|
quote! {
|
||||||
|
#api_crate::TransportMetadata::WebSocket {
|
||||||
|
client_to_server_frame: stringify!(#client_to_server),
|
||||||
|
server_to_client_frame: stringify!(#server_to_client),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
let request_kind = match operation.request_body.as_ref().map(|body| body.wire_kind) {
|
let request_kind = match operation.request_body.as_ref().map(|body| body.wire_kind) {
|
||||||
Some(RequestWireKind::Json) => quote!(#api_crate::WireKind::Json),
|
Some(RequestWireKind::Json) => quote!(#api_crate::WireKind::Json),
|
||||||
Some(RequestWireKind::Binary) => quote!(#api_crate::WireKind::Binary),
|
Some(RequestWireKind::Binary) => quote!(#api_crate::WireKind::Binary),
|
||||||
@@ -2333,11 +2661,13 @@ fn metadata_tokens(
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let rust_name = ¶meter.rust_name;
|
let rust_name = ¶meter.rust_name;
|
||||||
|
let ty = ¶meter.ty;
|
||||||
let wire_name = ¶meter.wire_name;
|
let wire_name = ¶meter.wire_name;
|
||||||
let location = parameter.location.tokens(api_crate);
|
let location = parameter.location.tokens(api_crate);
|
||||||
Some(quote! {
|
Some(quote! {
|
||||||
#api_crate::ParameterMetadata {
|
#api_crate::ParameterMetadata {
|
||||||
rust_name: #rust_name,
|
rust_name: #rust_name,
|
||||||
|
rust_type: stringify!(#ty),
|
||||||
wire_name: #wire_name,
|
wire_name: #wire_name,
|
||||||
location: #location,
|
location: #location,
|
||||||
}
|
}
|
||||||
@@ -2349,6 +2679,7 @@ fn metadata_tokens(
|
|||||||
operation_id: #operation_id,
|
operation_id: #operation_id,
|
||||||
method: #method,
|
method: #method,
|
||||||
path: #path,
|
path: #path,
|
||||||
|
transport: #transport,
|
||||||
parameters: &[#(#parameters),*],
|
parameters: &[#(#parameters),*],
|
||||||
request_body: #api_crate::BodyMetadata { wire_kind: #request_kind },
|
request_body: #api_crate::BodyMetadata { wire_kind: #request_kind },
|
||||||
success_responses: &[#(#success_responses),*],
|
success_responses: &[#(#success_responses),*],
|
||||||
|
|||||||
@@ -8,9 +8,11 @@
|
|||||||
use std::{fmt, str::FromStr};
|
use std::{fmt, str::FromStr};
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json, Router,
|
||||||
|
handler::Handler,
|
||||||
http::{HeaderMap, StatusCode},
|
http::{HeaderMap, StatusCode},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
|
routing::get,
|
||||||
};
|
};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
@@ -90,6 +92,26 @@ pub fn status(code: u16) -> StatusCode {
|
|||||||
StatusCode::from_u16(code).expect("#[api] validates HTTP status constants")
|
StatusCode::from_u16(code).expect("#[api] validates HTTP status constants")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mount a manual Axum upgrade handler at a contract-declared WebSocket route.
|
||||||
|
///
|
||||||
|
/// The operation marker supplies the only path and method authority. Requiring
|
||||||
|
/// [`crate::WebSocketOperation`] prevents HTTP operation markers from being registered through this
|
||||||
|
/// helper while Axum retains its normal route composition and collision behavior.
|
||||||
|
pub fn websocket_route<O, H, T, S>(router: Router<S>, handler: H) -> Router<S>
|
||||||
|
where
|
||||||
|
O: crate::WebSocketOperation,
|
||||||
|
H: Handler<T, S>,
|
||||||
|
T: 'static,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
debug_assert_eq!(O::METADATA.method, crate::HttpMethod::Get);
|
||||||
|
debug_assert!(matches!(
|
||||||
|
O::METADATA.transport,
|
||||||
|
crate::TransportMetadata::WebSocket { .. }
|
||||||
|
));
|
||||||
|
router.route(O::METADATA.path, get(handler))
|
||||||
|
}
|
||||||
|
|
||||||
/// Reexports used by generated router code.
|
/// Reexports used by generated router code.
|
||||||
pub mod framework {
|
pub mod framework {
|
||||||
pub use axum::{
|
pub use axum::{
|
||||||
|
|||||||
@@ -59,6 +59,34 @@
|
|||||||
//! `openapi = false` explicitly excludes an operation whose wire body cannot yet satisfy the
|
//! `openapi = false` explicitly excludes an operation whose wire body cannot yet satisfy the
|
||||||
//! strict OpenAPI schema boundary; Reqwest and Axum adapters are still generated.
|
//! strict OpenAPI schema boundary; Reqwest and Axum adapters are still generated.
|
||||||
//!
|
//!
|
||||||
|
//! WebSocket routes use a declaration-only associated type rather than a dummy unary method:
|
||||||
|
//!
|
||||||
|
//! ```
|
||||||
|
//! # mod websocket_example {
|
||||||
|
//! # use api_macros::api;
|
||||||
|
//! # pub struct ClientFrame;
|
||||||
|
//! # pub struct ServerFrame;
|
||||||
|
//! #[api]
|
||||||
|
//! pub trait EventApi {
|
||||||
|
//! #[websocket(
|
||||||
|
//! "/events/{stream_id}",
|
||||||
|
//! operation_id = "events.stream",
|
||||||
|
//! method = GET,
|
||||||
|
//! client_to_server = ClientFrame,
|
||||||
|
//! server_to_client = ServerFrame,
|
||||||
|
//! path_parameters = [stream_id: u64]
|
||||||
|
//! )]
|
||||||
|
//! type EventStream;
|
||||||
|
//! }
|
||||||
|
//! # }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! The declaration is removed from the emitted service trait and becomes a typed marker implementing
|
||||||
|
//! [`WebSocketOperation`]. It participates in the same operation/route inventory and path validation,
|
||||||
|
//! but never generates a Reqwest method, unary Axum adapter, or OpenAPI path. Under the `axum`
|
||||||
|
//! feature, [`axum::websocket_route`] mounts an existing manual upgrade handler at its declared GET
|
||||||
|
//! path without exposing framework WebSocket types to the contract.
|
||||||
|
//!
|
||||||
//! # Generated names
|
//! # Generated names
|
||||||
//!
|
//!
|
||||||
//! For `WidgetApi`, the macro emits `WidgetApiMetadata`, which implements [`ApiContract`],
|
//! For `WidgetApi`, the macro emits `WidgetApiMetadata`, which implements [`ApiContract`],
|
||||||
@@ -216,14 +244,27 @@ pub enum WireKind {
|
|||||||
Binary,
|
Binary,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stable metadata for one Rust method argument.
|
/// Stable metadata for one operation argument.
|
||||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
pub struct ParameterMetadata {
|
pub struct ParameterMetadata {
|
||||||
pub rust_name: &'static str,
|
pub rust_name: &'static str,
|
||||||
|
pub rust_type: &'static str,
|
||||||
pub wire_name: &'static str,
|
pub wire_name: &'static str,
|
||||||
pub location: ParameterLocation,
|
pub location: ParameterLocation,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Transport semantics declared by one operation.
|
||||||
|
///
|
||||||
|
/// WebSocket frame directions remain distinct even when both directions use the same Rust type.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub enum TransportMetadata {
|
||||||
|
Http,
|
||||||
|
WebSocket {
|
||||||
|
client_to_server_frame: &'static str,
|
||||||
|
server_to_client_frame: &'static str,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
/// Stable metadata for a request or response body.
|
/// Stable metadata for a request or response body.
|
||||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
pub struct BodyMetadata {
|
pub struct BodyMetadata {
|
||||||
@@ -253,6 +294,7 @@ pub struct OperationMetadata {
|
|||||||
pub operation_id: &'static str,
|
pub operation_id: &'static str,
|
||||||
pub method: HttpMethod,
|
pub method: HttpMethod,
|
||||||
pub path: &'static str,
|
pub path: &'static str,
|
||||||
|
pub transport: TransportMetadata,
|
||||||
pub parameters: &'static [ParameterMetadata],
|
pub parameters: &'static [ParameterMetadata],
|
||||||
pub request_body: BodyMetadata,
|
pub request_body: BodyMetadata,
|
||||||
/// Every declared successful response, including status-specific body and header shape.
|
/// Every declared successful response, including status-specific body and header shape.
|
||||||
@@ -280,6 +322,21 @@ pub trait Operation {
|
|||||||
const METADATA: OperationMetadata;
|
const METADATA: OperationMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compile-time connection between a WebSocket route and its directional frame authorities.
|
||||||
|
///
|
||||||
|
/// This is intentionally separate from [`Operation`]: WebSocket routes do not have unary request,
|
||||||
|
/// response, or error bodies and therefore cannot be passed to HTTP adapter surfaces.
|
||||||
|
pub trait WebSocketOperation {
|
||||||
|
/// Tuple of path parameter types in path-template order.
|
||||||
|
type PathParameters;
|
||||||
|
/// Frame received by the server from the connected client.
|
||||||
|
type ClientToServerFrame;
|
||||||
|
/// Frame sent by the server to the connected client.
|
||||||
|
type ServerToClientFrame;
|
||||||
|
|
||||||
|
const METADATA: OperationMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
/// Public JSON error that can select its concrete HTTP status at the server boundary.
|
/// Public JSON error that can select its concrete HTTP status at the server boundary.
|
||||||
///
|
///
|
||||||
/// `error_status` remains the contract's default/documented status. Implementations may return a
|
/// `error_status` remains the contract's default/documented status. Implementations may return a
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
#![allow(async_fn_in_trait)]
|
#![allow(async_fn_in_trait)]
|
||||||
|
|
||||||
use api_macros::{
|
use api_macros::{
|
||||||
ApiContract, BinaryBody, HttpMethod, NoBody, Operation, ParameterLocation, WireKind, api,
|
ApiContract, BinaryBody, HttpMethod, NoBody, Operation, ParameterLocation, TransportMetadata,
|
||||||
|
WebSocketOperation, WireKind, api,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct CreateWidget;
|
pub struct CreateWidget;
|
||||||
@@ -9,6 +10,24 @@ pub struct Widget;
|
|||||||
pub struct Lookup;
|
pub struct Lookup;
|
||||||
pub struct PublicError;
|
pub struct PublicError;
|
||||||
pub struct SearchResult<T>(std::marker::PhantomData<T>);
|
pub struct SearchResult<T>(std::marker::PhantomData<T>);
|
||||||
|
pub struct ClientFrame;
|
||||||
|
pub struct ServerFrame;
|
||||||
|
|
||||||
|
#[api]
|
||||||
|
pub trait MixedTransportApi {
|
||||||
|
#[get("/status")]
|
||||||
|
async fn status(&self) -> Widget;
|
||||||
|
|
||||||
|
#[websocket(
|
||||||
|
"/widgets/{widget_id}/events",
|
||||||
|
operation_id = "widgets.events",
|
||||||
|
method = GET,
|
||||||
|
client_to_server = ClientFrame,
|
||||||
|
server_to_client = ServerFrame,
|
||||||
|
path_parameters = [widget_id: u64]
|
||||||
|
)]
|
||||||
|
type WidgetEvents;
|
||||||
|
}
|
||||||
|
|
||||||
#[api]
|
#[api]
|
||||||
pub trait WidgetApi {
|
pub trait WidgetApi {
|
||||||
@@ -106,11 +125,13 @@ fn expansion_exposes_deterministic_metadata_and_type_connections() {
|
|||||||
assert_eq!(operations[0], create);
|
assert_eq!(operations[0], create);
|
||||||
assert_eq!(create.method, HttpMethod::Post);
|
assert_eq!(create.method, HttpMethod::Post);
|
||||||
assert_eq!(create.path, "/widgets");
|
assert_eq!(create.path, "/widgets");
|
||||||
|
assert_eq!(create.transport, TransportMetadata::Http);
|
||||||
assert_eq!(create.request_body.wire_kind, WireKind::Json);
|
assert_eq!(create.request_body.wire_kind, WireKind::Json);
|
||||||
assert_eq!(create.success_responses[0].status, 201);
|
assert_eq!(create.success_responses[0].status, 201);
|
||||||
assert_eq!(create.success_responses[0].body.wire_kind, WireKind::Json);
|
assert_eq!(create.success_responses[0].body.wire_kind, WireKind::Json);
|
||||||
assert_eq!(create.error_responses[0].status, 422);
|
assert_eq!(create.error_responses[0].status, 422);
|
||||||
assert_eq!(create.parameters[0].location, ParameterLocation::Body);
|
assert_eq!(create.parameters[0].location, ParameterLocation::Body);
|
||||||
|
assert_eq!(create.parameters[0].rust_type, "CreateWidget");
|
||||||
assert_eq!(create.parameters[1].location, ParameterLocation::Header);
|
assert_eq!(create.parameters[1].location, ParameterLocation::Header);
|
||||||
assert_eq!(create.parameters[1].wire_name, "x-request-id");
|
assert_eq!(create.parameters[1].wire_name, "x-request-id");
|
||||||
|
|
||||||
@@ -160,4 +181,28 @@ fn expansion_exposes_deterministic_metadata_and_type_connections() {
|
|||||||
fn assert_empty_response<O: Operation<ResponseBody = NoBody, ErrorBody = NoBody>>() {}
|
fn assert_empty_response<O: Operation<ResponseBody = NoBody, ErrorBody = NoBody>>() {}
|
||||||
assert_empty_request::<widget_api_operations::Lookup>();
|
assert_empty_request::<widget_api_operations::Lookup>();
|
||||||
assert_empty_response::<widget_api_operations::Delete>();
|
assert_empty_response::<widget_api_operations::Delete>();
|
||||||
|
|
||||||
|
fn assert_websocket_types<
|
||||||
|
O: WebSocketOperation<
|
||||||
|
PathParameters = (u64,),
|
||||||
|
ClientToServerFrame = ClientFrame,
|
||||||
|
ServerToClientFrame = ServerFrame,
|
||||||
|
>,
|
||||||
|
>() {
|
||||||
|
}
|
||||||
|
assert_websocket_types::<mixed_transport_api_operations::WidgetEvents>();
|
||||||
|
let websocket = <mixed_transport_api_operations::WidgetEvents as WebSocketOperation>::METADATA;
|
||||||
|
assert_eq!(websocket.method, HttpMethod::Get);
|
||||||
|
assert_eq!(websocket.path, "/widgets/{widget_id}/events");
|
||||||
|
assert_eq!(websocket.parameters[0].rust_name, "widget_id");
|
||||||
|
assert_eq!(websocket.parameters[0].rust_type, "u64");
|
||||||
|
assert!(websocket.success_responses.is_empty());
|
||||||
|
assert!(websocket.error_responses.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
websocket.transport,
|
||||||
|
TransportMetadata::WebSocket {
|
||||||
|
client_to_server_frame: "ClientFrame",
|
||||||
|
server_to_client_frame: "ServerFrame",
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,26 @@ use std::{
|
|||||||
use api_macros::{api, reqwest as api_reqwest};
|
use api_macros::{api, reqwest as api_reqwest};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub struct NonSerdeClientFrame;
|
||||||
|
pub struct NonSerdeServerFrame;
|
||||||
|
|
||||||
|
#[api(reqwest, axum)]
|
||||||
|
pub trait WebSocketOnlyApi {
|
||||||
|
#[websocket(
|
||||||
|
"/events/{stream_id}",
|
||||||
|
operation_id = "events.websocket",
|
||||||
|
method = GET,
|
||||||
|
client_to_server = NonSerdeClientFrame,
|
||||||
|
server_to_client = NonSerdeServerFrame,
|
||||||
|
path_parameters = [stream_id: u64]
|
||||||
|
)]
|
||||||
|
type Events;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct WebSocketOnlyService;
|
||||||
|
impl WebSocketOnlyApi for WebSocketOnlyService {}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
pub struct CreateWidget {
|
pub struct CreateWidget {
|
||||||
name: String,
|
name: String,
|
||||||
@@ -287,6 +307,40 @@ async fn slow() -> &'static str {
|
|||||||
"{}"
|
"{}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn manual_websocket_route(
|
||||||
|
api_macros::axum::framework::Path(stream_id): api_macros::axum::framework::Path<u64>,
|
||||||
|
) -> String {
|
||||||
|
format!("stream:{stream_id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn websocket_declarations_skip_unary_adapters_and_mount_manual_handlers() {
|
||||||
|
let _client = WebSocketOnlyApiClient::try_new("http://127.0.0.1:1")
|
||||||
|
.expect("WebSocket-only contracts still provide a client with no unary methods");
|
||||||
|
let app = api_macros::axum::websocket_route::<web_socket_only_api_operations::Events, _, _, _>(
|
||||||
|
api_macros::axum::framework::Router::new(),
|
||||||
|
manual_websocket_route,
|
||||||
|
);
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||||
|
.await
|
||||||
|
.expect("bind fixture server");
|
||||||
|
let address = listener.local_addr().expect("fixture address");
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
api_macros::axum::framework::serve(listener, app)
|
||||||
|
.await
|
||||||
|
.expect("serve fixture WebSocket route");
|
||||||
|
});
|
||||||
|
|
||||||
|
let body = reqwest::get(format!("http://{address}/events/42"))
|
||||||
|
.await
|
||||||
|
.expect("manual route response")
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.expect("manual route body");
|
||||||
|
assert_eq!(body, "stream:42");
|
||||||
|
server.abort();
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn generated_client_and_router_follow_the_normalized_contract() {
|
async fn generated_client_and_router_follow_the_normalized_contract() {
|
||||||
let app = WidgetApiAxum::router(WidgetService).merge(
|
let app = WidgetApiAxum::router(WidgetService).merge(
|
||||||
|
|||||||
@@ -81,6 +81,9 @@ impl OpenApiSchema for PublicError {}
|
|||||||
|
|
||||||
type OptionalRequestId = Option<String>;
|
type OptionalRequestId = Option<String>;
|
||||||
|
|
||||||
|
pub struct ClientFrame;
|
||||||
|
pub struct ServerFrame;
|
||||||
|
|
||||||
#[api(openapi)]
|
#[api(openapi)]
|
||||||
pub trait FixtureApi {
|
pub trait FixtureApi {
|
||||||
#[post(
|
#[post(
|
||||||
@@ -113,6 +116,16 @@ pub trait FixtureApi {
|
|||||||
]
|
]
|
||||||
)]
|
)]
|
||||||
async fn conditional(&self) -> fixture_api_responses::Conditional;
|
async fn conditional(&self) -> fixture_api_responses::Conditional;
|
||||||
|
|
||||||
|
#[websocket(
|
||||||
|
"/widgets/{widget_id}/events",
|
||||||
|
operation_id = "widgets.events",
|
||||||
|
method = GET,
|
||||||
|
client_to_server = ClientFrame,
|
||||||
|
server_to_client = ServerFrame,
|
||||||
|
path_parameters = [widget_id: u32]
|
||||||
|
)]
|
||||||
|
type WidgetEvents;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn document() -> api_macros::openapi::OpenApiDocument {
|
fn document() -> api_macros::openapi::OpenApiDocument {
|
||||||
@@ -188,6 +201,10 @@ fn collect_schema_references<'a>(value: &'a Value, references: &mut Vec<&'a str>
|
|||||||
#[test]
|
#[test]
|
||||||
fn operations_and_components_preserve_the_wire_contract() {
|
fn operations_and_components_preserve_the_wire_contract() {
|
||||||
let value = document().as_value().clone();
|
let value = document().as_value().clone();
|
||||||
|
assert!(
|
||||||
|
value["paths"]["/widgets/{widget_id}/events"].is_null(),
|
||||||
|
"WebSocket transports must not be projected as unary OpenAPI operations"
|
||||||
|
);
|
||||||
let create = &value["paths"]["/widgets/{widget_id}"]["post"];
|
let create = &value["paths"]["/widgets/{widget_id}"]["post"];
|
||||||
assert_eq!(create["operationId"], "widgets.create");
|
assert_eq!(create["operationId"], "widgets.create");
|
||||||
assert_eq!(create["security"], json!([{ "bearerAuth": [] }]));
|
assert_eq!(create["security"], json!([{ "bearerAuth": [] }]));
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
use api_macros::api;
|
||||||
|
|
||||||
|
pub struct Frame;
|
||||||
|
|
||||||
|
#[api]
|
||||||
|
pub trait DuplicateWebSocketPathParameterApi {
|
||||||
|
#[websocket(
|
||||||
|
"/widgets/{widget_id}/events",
|
||||||
|
method = GET,
|
||||||
|
client_to_server = Frame,
|
||||||
|
server_to_client = Frame,
|
||||||
|
path_parameters = [widget_id: u64, widget_id: String]
|
||||||
|
)]
|
||||||
|
type Events;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: duplicate path parameter `widget_id`
|
||||||
|
--> tests/ui/duplicate_websocket_path_parameter.rs:12:44
|
||||||
|
|
|
||||||
|
12 | path_parameters = [widget_id: u64, widget_id: String]
|
||||||
|
| ^^^^^^^^^
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
use api_macros::api;
|
||||||
|
|
||||||
|
pub struct Frame;
|
||||||
|
pub struct Response;
|
||||||
|
|
||||||
|
#[api]
|
||||||
|
pub trait DuplicateTransportRouteApi {
|
||||||
|
#[get("/widgets/{widget_id}/events")]
|
||||||
|
async fn events(&self, #[path] widget_id: u64) -> Response;
|
||||||
|
|
||||||
|
#[websocket(
|
||||||
|
"/widgets/{widget_id}/events",
|
||||||
|
method = GET,
|
||||||
|
client_to_server = Frame,
|
||||||
|
server_to_client = Frame,
|
||||||
|
path_parameters = [widget_id: u64]
|
||||||
|
)]
|
||||||
|
type WidgetEvents;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: duplicate route for GET `/widgets/{widget_id}/events`
|
||||||
|
--> tests/ui/duplicate_websocket_route.rs:18:10
|
||||||
|
|
|
||||||
|
18 | type WidgetEvents;
|
||||||
|
| ^^^^^^^^^^^^
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
use api_macros::api;
|
||||||
|
|
||||||
|
pub struct Frame;
|
||||||
|
|
||||||
|
#[api]
|
||||||
|
pub trait InvalidWebSocketAlternateStatusApi {
|
||||||
|
#[websocket(
|
||||||
|
"/events",
|
||||||
|
method = GET,
|
||||||
|
client_to_server = Frame,
|
||||||
|
server_to_client = Frame,
|
||||||
|
path_parameters = [],
|
||||||
|
alternate_status = 204
|
||||||
|
)]
|
||||||
|
type Events;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: WebSocket operations cannot declare unary success responses or statuses
|
||||||
|
--> tests/ui/invalid_websocket_alternate_status.rs:13:9
|
||||||
|
|
|
||||||
|
13 | alternate_status = 204
|
||||||
|
| ^^^^^^^^^^^^^^^^
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
use api_macros::api;
|
||||||
|
|
||||||
|
pub struct Frame;
|
||||||
|
pub struct Body;
|
||||||
|
|
||||||
|
#[api]
|
||||||
|
pub trait InvalidWebSocketBinaryBodyApi {
|
||||||
|
#[websocket(
|
||||||
|
"/events",
|
||||||
|
method = GET,
|
||||||
|
client_to_server = Frame,
|
||||||
|
server_to_client = Frame,
|
||||||
|
path_parameters = [],
|
||||||
|
binary = Body
|
||||||
|
)]
|
||||||
|
type Events;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: WebSocket operations cannot declare JSON or binary request bodies
|
||||||
|
--> tests/ui/invalid_websocket_binary_body.rs:14:9
|
||||||
|
|
|
||||||
|
14 | binary = Body
|
||||||
|
| ^^^^^^
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
use api_macros::api;
|
||||||
|
|
||||||
|
pub struct Frame;
|
||||||
|
pub struct Body;
|
||||||
|
|
||||||
|
#[api]
|
||||||
|
pub trait InvalidWebSocketBodyApi {
|
||||||
|
#[websocket(
|
||||||
|
"/events",
|
||||||
|
method = GET,
|
||||||
|
client_to_server = Frame,
|
||||||
|
server_to_client = Frame,
|
||||||
|
path_parameters = [],
|
||||||
|
body = Body
|
||||||
|
)]
|
||||||
|
type Events;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: WebSocket operations cannot declare JSON or binary request bodies
|
||||||
|
--> tests/ui/invalid_websocket_body.rs:14:9
|
||||||
|
|
|
||||||
|
14 | body = Body
|
||||||
|
| ^^^^
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
use api_macros::api;
|
||||||
|
|
||||||
|
pub struct Frame;
|
||||||
|
|
||||||
|
#[api]
|
||||||
|
pub trait InvalidWebSocketFrameApi {
|
||||||
|
#[websocket(
|
||||||
|
"/events",
|
||||||
|
method = GET,
|
||||||
|
client_to_server = (Frame, Frame),
|
||||||
|
server_to_client = Frame,
|
||||||
|
path_parameters = []
|
||||||
|
)]
|
||||||
|
type Events;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: WebSocket frame authorities must use direct named Rust types
|
||||||
|
--> tests/ui/invalid_websocket_frame.rs:10:28
|
||||||
|
|
|
||||||
|
10 | client_to_server = (Frame, Frame),
|
||||||
|
| ^^^^^^^^^^^^^^
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
use api_macros::api;
|
||||||
|
|
||||||
|
pub struct Frame;
|
||||||
|
|
||||||
|
#[api]
|
||||||
|
pub trait InvalidWebSocketMethodApi {
|
||||||
|
#[websocket(
|
||||||
|
"/events",
|
||||||
|
method = POST,
|
||||||
|
client_to_server = Frame,
|
||||||
|
server_to_client = Frame,
|
||||||
|
path_parameters = []
|
||||||
|
)]
|
||||||
|
type Events;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: WebSocket operations require `method = GET`
|
||||||
|
--> tests/ui/invalid_websocket_method.rs:9:18
|
||||||
|
|
|
||||||
|
9 | method = POST,
|
||||||
|
| ^^^^
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
use api_macros::api;
|
||||||
|
|
||||||
|
pub struct Frame;
|
||||||
|
|
||||||
|
#[api]
|
||||||
|
pub trait InvalidWebSocketResponseApi {
|
||||||
|
#[websocket(
|
||||||
|
"/events",
|
||||||
|
method = GET,
|
||||||
|
client_to_server = Frame,
|
||||||
|
server_to_client = Frame,
|
||||||
|
path_parameters = [],
|
||||||
|
status = 101
|
||||||
|
)]
|
||||||
|
type Events;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: WebSocket operations cannot declare unary success responses or statuses
|
||||||
|
--> tests/ui/invalid_websocket_response.rs:13:9
|
||||||
|
|
|
||||||
|
13 | status = 101
|
||||||
|
| ^^^^^^
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
use api_macros::api;
|
||||||
|
|
||||||
|
pub struct Frame;
|
||||||
|
pub struct Response;
|
||||||
|
|
||||||
|
#[api]
|
||||||
|
pub trait InvalidWebSocketResponseBodyApi {
|
||||||
|
#[websocket(
|
||||||
|
"/events",
|
||||||
|
method = GET,
|
||||||
|
client_to_server = Frame,
|
||||||
|
server_to_client = Frame,
|
||||||
|
path_parameters = [],
|
||||||
|
response = Response
|
||||||
|
)]
|
||||||
|
type Events;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: WebSocket operations cannot declare unary success responses or statuses
|
||||||
|
--> tests/ui/invalid_websocket_response_body.rs:14:9
|
||||||
|
|
|
||||||
|
14 | response = Response
|
||||||
|
| ^^^^^^^^
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
use api_macros::api;
|
||||||
|
|
||||||
|
pub struct Frame;
|
||||||
|
|
||||||
|
#[api]
|
||||||
|
pub trait MissingWebSocketFrameApi {
|
||||||
|
#[websocket(
|
||||||
|
"/events",
|
||||||
|
method = GET,
|
||||||
|
client_to_server = Frame,
|
||||||
|
path_parameters = []
|
||||||
|
)]
|
||||||
|
type Events;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: WebSocket operations require `server_to_client = FrameType`
|
||||||
|
--> tests/ui/missing_websocket_frame.rs:8:9
|
||||||
|
|
|
||||||
|
8 | "/events",
|
||||||
|
| ^^^^^^^^^
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
use api_macros::api;
|
||||||
|
|
||||||
|
pub struct Frame;
|
||||||
|
|
||||||
|
#[api]
|
||||||
|
pub trait InvalidWebSocketPathApi {
|
||||||
|
#[websocket(
|
||||||
|
"/widgets/{widget_id}/events",
|
||||||
|
method = GET,
|
||||||
|
client_to_server = Frame,
|
||||||
|
server_to_client = Frame,
|
||||||
|
path_parameters = [other_id: u64]
|
||||||
|
)]
|
||||||
|
type Events;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: path placeholder(s) have no matching path parameter: widget_id
|
||||||
|
--> tests/ui/websocket_path_mismatch.rs:8:9
|
||||||
|
|
|
||||||
|
8 | "/widgets/{widget_id}/events",
|
||||||
|
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
use api_macros::api;
|
||||||
|
|
||||||
|
pub struct Frame;
|
||||||
|
|
||||||
|
#[api]
|
||||||
|
pub trait UnusedWebSocketPathParameterApi {
|
||||||
|
#[websocket(
|
||||||
|
"/events",
|
||||||
|
method = GET,
|
||||||
|
client_to_server = Frame,
|
||||||
|
server_to_client = Frame,
|
||||||
|
path_parameters = [unused: u64]
|
||||||
|
)]
|
||||||
|
type Events;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: path parameter(s) have no matching placeholder: unused
|
||||||
|
--> tests/ui/websocket_unused_path_parameter.rs:8:9
|
||||||
|
|
|
||||||
|
8 | "/events",
|
||||||
|
| ^^^^^^^^^
|
||||||
@@ -9,7 +9,7 @@ use std::collections::BTreeMap;
|
|||||||
use api_macros::api;
|
use api_macros::api;
|
||||||
pub use api_macros::axum as server_support;
|
pub use api_macros::axum as server_support;
|
||||||
pub use api_macros::reqwest as client_support;
|
pub use api_macros::reqwest as client_support;
|
||||||
pub use api_macros::{ApiContract, BinaryBody, HttpMethod};
|
pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, WebSocketOperation};
|
||||||
pub mod repository_openapi_typescript;
|
pub mod repository_openapi_typescript;
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -2885,10 +2885,147 @@ pub trait ServerApi {
|
|||||||
#[path] objective_id: String,
|
#[path] objective_id: String,
|
||||||
#[path] ticket_id: String,
|
#[path] ticket_id: String,
|
||||||
) -> Result<ObjectiveDetail, RepositoryApiError>;
|
) -> Result<ObjectiveDetail, RepositoryApiError>;
|
||||||
|
|
||||||
|
#[websocket(
|
||||||
|
"/api/w/{workspace_id}/protocol/ws",
|
||||||
|
operation_id = "workspace_protocol_websocket",
|
||||||
|
method = GET,
|
||||||
|
client_to_server = protocol::subscription::SubscriptionFrame,
|
||||||
|
server_to_client = protocol::subscription::SubscriptionFrame,
|
||||||
|
path_parameters = [workspace_id: String]
|
||||||
|
)]
|
||||||
|
type WorkspaceProtocolWebSocket;
|
||||||
|
|
||||||
|
#[websocket(
|
||||||
|
"/api/w/{workspace_id}/external-workdir-grants/{grant_id}/provider",
|
||||||
|
operation_id = "external_workdir_provider_websocket",
|
||||||
|
method = GET,
|
||||||
|
client_to_server = workdir::external::ExternalWorkdirProviderFrame,
|
||||||
|
server_to_client = workdir::external::ExternalWorkdirServerFrame,
|
||||||
|
path_parameters = [workspace_id: String, grant_id: String]
|
||||||
|
)]
|
||||||
|
type ExternalWorkdirProviderWebSocket;
|
||||||
|
|
||||||
|
#[websocket(
|
||||||
|
"/api/runtimes/{runtime_id}/workers/{worker_id}/protocol/ws",
|
||||||
|
operation_id = "worker_protocol_websocket_alias",
|
||||||
|
method = GET,
|
||||||
|
client_to_server = protocol::Method,
|
||||||
|
server_to_client = protocol::Event,
|
||||||
|
path_parameters = [runtime_id: String, worker_id: String]
|
||||||
|
)]
|
||||||
|
type WorkerProtocolWebSocketAlias;
|
||||||
|
|
||||||
|
#[websocket(
|
||||||
|
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/protocol/ws",
|
||||||
|
operation_id = "workspace_worker_protocol_websocket",
|
||||||
|
method = GET,
|
||||||
|
client_to_server = protocol::Method,
|
||||||
|
server_to_client = protocol::Event,
|
||||||
|
path_parameters = [workspace_id: String, runtime_id: String, worker_id: String]
|
||||||
|
)]
|
||||||
|
type WorkspaceWorkerProtocolWebSocket;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Digest of the fully rendered canonical contract with its digest slot normalized.
|
/// Stable fingerprint for one complete operation declaration.
|
||||||
pub fn canonical_openapi_source_digest() -> Result<String, api_macros::openapi::OpenApiError> {
|
///
|
||||||
|
/// The fingerprint includes transport kind, directional WebSocket frame authorities, path
|
||||||
|
/// parameter names and types, and every unary body/response shape. Length-prefixing keeps the
|
||||||
|
/// representation unambiguous without introducing a second schema format.
|
||||||
|
pub fn operation_fingerprint(operation: &api_macros::OperationMetadata) -> String {
|
||||||
|
fn field(hasher: &mut Sha256, value: &str) {
|
||||||
|
hasher.update((value.len() as u64).to_be_bytes());
|
||||||
|
hasher.update(value.as_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
field(&mut hasher, operation.operation_id);
|
||||||
|
field(&mut hasher, http_method_name(operation.method));
|
||||||
|
field(&mut hasher, operation.path);
|
||||||
|
match operation.transport {
|
||||||
|
api_macros::TransportMetadata::Http => field(&mut hasher, "http"),
|
||||||
|
api_macros::TransportMetadata::WebSocket {
|
||||||
|
client_to_server_frame,
|
||||||
|
server_to_client_frame,
|
||||||
|
} => {
|
||||||
|
field(&mut hasher, "websocket");
|
||||||
|
field(&mut hasher, client_to_server_frame);
|
||||||
|
field(&mut hasher, server_to_client_frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
field(&mut hasher, "parameters");
|
||||||
|
field(&mut hasher, &operation.parameters.len().to_string());
|
||||||
|
for parameter in operation.parameters {
|
||||||
|
field(&mut hasher, parameter.rust_name);
|
||||||
|
field(&mut hasher, parameter.rust_type);
|
||||||
|
field(&mut hasher, parameter.wire_name);
|
||||||
|
field(&mut hasher, parameter_location_name(parameter.location));
|
||||||
|
}
|
||||||
|
field(
|
||||||
|
&mut hasher,
|
||||||
|
wire_kind_name(operation.request_body.wire_kind),
|
||||||
|
);
|
||||||
|
field(&mut hasher, "successes");
|
||||||
|
field(&mut hasher, &operation.success_responses.len().to_string());
|
||||||
|
for response in operation.success_responses {
|
||||||
|
field(&mut hasher, &response.status.to_string());
|
||||||
|
field(&mut hasher, wire_kind_name(response.body.wire_kind));
|
||||||
|
field(&mut hasher, &response.headers.len().to_string());
|
||||||
|
for header in response.headers {
|
||||||
|
field(&mut hasher, header.wire_name);
|
||||||
|
field(&mut hasher, header.rust_type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
field(&mut hasher, "errors");
|
||||||
|
field(&mut hasher, &operation.error_responses.len().to_string());
|
||||||
|
for response in operation.error_responses {
|
||||||
|
field(&mut hasher, &response.status.to_string());
|
||||||
|
field(&mut hasher, wire_kind_name(response.body.wire_kind));
|
||||||
|
}
|
||||||
|
let digest = hasher.finalize();
|
||||||
|
let mut encoded = String::with_capacity("sha256:".len() + digest.len() * 2);
|
||||||
|
encoded.push_str("sha256:");
|
||||||
|
for byte in digest {
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail");
|
||||||
|
}
|
||||||
|
encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
fn http_method_name(method: HttpMethod) -> &'static str {
|
||||||
|
match method {
|
||||||
|
HttpMethod::Get => "GET",
|
||||||
|
HttpMethod::Post => "POST",
|
||||||
|
HttpMethod::Put => "PUT",
|
||||||
|
HttpMethod::Patch => "PATCH",
|
||||||
|
HttpMethod::Delete => "DELETE",
|
||||||
|
HttpMethod::Head => "HEAD",
|
||||||
|
HttpMethod::Options => "OPTIONS",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parameter_location_name(location: api_macros::ParameterLocation) -> &'static str {
|
||||||
|
match location {
|
||||||
|
api_macros::ParameterLocation::Path => "path",
|
||||||
|
api_macros::ParameterLocation::Query => "query",
|
||||||
|
api_macros::ParameterLocation::Header => "header",
|
||||||
|
api_macros::ParameterLocation::Body => "body",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wire_kind_name(kind: api_macros::WireKind) -> &'static str {
|
||||||
|
match kind {
|
||||||
|
api_macros::WireKind::Empty => "empty",
|
||||||
|
api_macros::WireKind::Json => "json",
|
||||||
|
api_macros::WireKind::Streaming => "streaming",
|
||||||
|
api_macros::WireKind::Multipart => "multipart",
|
||||||
|
api_macros::WireKind::Binary => "binary",
|
||||||
|
_ => "reserved",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Digest of the complete canonical contract with the OpenAPI digest slot normalized.
|
||||||
|
pub fn canonical_contract_source_digest() -> Result<String, api_macros::openapi::OpenApiError> {
|
||||||
const NORMALIZED_DIGEST: &str =
|
const NORMALIZED_DIGEST: &str =
|
||||||
"sha256:0000000000000000000000000000000000000000000000000000000000000000";
|
"sha256:0000000000000000000000000000000000000000000000000000000000000000";
|
||||||
let normalized = server_api_openapi(api_macros::openapi::OpenApiInfo {
|
let normalized = server_api_openapi(api_macros::openapi::OpenApiInfo {
|
||||||
@@ -2897,7 +3034,15 @@ pub fn canonical_openapi_source_digest() -> Result<String, api_macros::openapi::
|
|||||||
source_digest: NORMALIZED_DIGEST,
|
source_digest: NORMALIZED_DIGEST,
|
||||||
})?
|
})?
|
||||||
.to_json()?;
|
.to_json()?;
|
||||||
let digest = Sha256::digest(normalized.as_bytes());
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update((normalized.len() as u64).to_be_bytes());
|
||||||
|
hasher.update(normalized.as_bytes());
|
||||||
|
for operation in ServerApiMetadata::OPERATIONS {
|
||||||
|
let fingerprint = operation_fingerprint(operation);
|
||||||
|
hasher.update((fingerprint.len() as u64).to_be_bytes());
|
||||||
|
hasher.update(fingerprint.as_bytes());
|
||||||
|
}
|
||||||
|
let digest = hasher.finalize();
|
||||||
let mut encoded = String::with_capacity("sha256:".len() + digest.len() * 2);
|
let mut encoded = String::with_capacity("sha256:".len() + digest.len() * 2);
|
||||||
encoded.push_str("sha256:");
|
encoded.push_str("sha256:");
|
||||||
for byte in digest {
|
for byte in digest {
|
||||||
@@ -2910,7 +3055,7 @@ pub fn canonical_openapi_source_digest() -> Result<String, api_macros::openapi::
|
|||||||
/// Build the deployment-independent canonical ServerApi OpenAPI document.
|
/// Build the deployment-independent canonical ServerApi OpenAPI document.
|
||||||
pub fn canonical_openapi_document()
|
pub fn canonical_openapi_document()
|
||||||
-> Result<api_macros::openapi::OpenApiDocument, api_macros::openapi::OpenApiError> {
|
-> Result<api_macros::openapi::OpenApiDocument, api_macros::openapi::OpenApiError> {
|
||||||
let source_digest = canonical_openapi_source_digest()?;
|
let source_digest = canonical_contract_source_digest()?;
|
||||||
server_api_openapi(api_macros::openapi::OpenApiInfo {
|
server_api_openapi(api_macros::openapi::OpenApiInfo {
|
||||||
title: "Yoi Server API",
|
title: "Yoi Server API",
|
||||||
version: env!("CARGO_PKG_VERSION"),
|
version: env!("CARGO_PKG_VERSION"),
|
||||||
@@ -9124,6 +9269,128 @@ mod skill_typescript_tests {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn websocket_operations_preserve_routes_and_directional_frame_authorities() {
|
||||||
|
use api_macros::{TransportMetadata, WebSocketOperation};
|
||||||
|
|
||||||
|
fn workspace_protocol<O>()
|
||||||
|
where
|
||||||
|
O: WebSocketOperation<
|
||||||
|
PathParameters = (String,),
|
||||||
|
ClientToServerFrame = protocol::subscription::SubscriptionFrame,
|
||||||
|
ServerToClientFrame = protocol::subscription::SubscriptionFrame,
|
||||||
|
>,
|
||||||
|
{
|
||||||
|
}
|
||||||
|
fn external_provider<O>()
|
||||||
|
where
|
||||||
|
O: WebSocketOperation<
|
||||||
|
PathParameters = (String, String),
|
||||||
|
ClientToServerFrame = workdir::external::ExternalWorkdirProviderFrame,
|
||||||
|
ServerToClientFrame = workdir::external::ExternalWorkdirServerFrame,
|
||||||
|
>,
|
||||||
|
{
|
||||||
|
}
|
||||||
|
fn direct_worker<O, P>()
|
||||||
|
where
|
||||||
|
O: WebSocketOperation<
|
||||||
|
PathParameters = P,
|
||||||
|
ClientToServerFrame = protocol::Method,
|
||||||
|
ServerToClientFrame = protocol::Event,
|
||||||
|
>,
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
workspace_protocol::<server_api_operations::WorkspaceProtocolWebSocket>();
|
||||||
|
external_provider::<server_api_operations::ExternalWorkdirProviderWebSocket>();
|
||||||
|
direct_worker::<server_api_operations::WorkerProtocolWebSocketAlias, (String, String)>();
|
||||||
|
direct_worker::<
|
||||||
|
server_api_operations::WorkspaceWorkerProtocolWebSocket,
|
||||||
|
(String, String, String),
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (operation_id, path, client, server) in [
|
||||||
|
(
|
||||||
|
"workspace_protocol_websocket",
|
||||||
|
"/api/w/{workspace_id}/protocol/ws",
|
||||||
|
"protocol :: subscription :: SubscriptionFrame",
|
||||||
|
"protocol :: subscription :: SubscriptionFrame",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"external_workdir_provider_websocket",
|
||||||
|
"/api/w/{workspace_id}/external-workdir-grants/{grant_id}/provider",
|
||||||
|
"workdir :: external :: ExternalWorkdirProviderFrame",
|
||||||
|
"workdir :: external :: ExternalWorkdirServerFrame",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"worker_protocol_websocket_alias",
|
||||||
|
"/api/runtimes/{runtime_id}/workers/{worker_id}/protocol/ws",
|
||||||
|
"protocol :: Method",
|
||||||
|
"protocol :: Event",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"workspace_worker_protocol_websocket",
|
||||||
|
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/protocol/ws",
|
||||||
|
"protocol :: Method",
|
||||||
|
"protocol :: Event",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let operation = ServerApiMetadata::OPERATIONS
|
||||||
|
.iter()
|
||||||
|
.find(|operation| operation.operation_id == operation_id)
|
||||||
|
.unwrap_or_else(|| panic!("missing WebSocket operation {operation_id}"));
|
||||||
|
assert_eq!(operation.method, HttpMethod::Get);
|
||||||
|
assert_eq!(operation.path, path);
|
||||||
|
assert!(operation.success_responses.is_empty());
|
||||||
|
assert!(operation.error_responses.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
operation.request_body.wire_kind,
|
||||||
|
api_macros::WireKind::Empty
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
operation.transport,
|
||||||
|
TransportMetadata::WebSocket {
|
||||||
|
client_to_server_frame: client,
|
||||||
|
server_to_client_frame: server,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert!(operation_fingerprint(operation).starts_with("sha256:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let external = *ServerApiMetadata::OPERATIONS
|
||||||
|
.iter()
|
||||||
|
.find(|operation| operation.operation_id == "external_workdir_provider_websocket")
|
||||||
|
.expect("external provider WebSocket operation");
|
||||||
|
let swapped = api_macros::OperationMetadata {
|
||||||
|
transport: TransportMetadata::WebSocket {
|
||||||
|
client_to_server_frame: "workdir :: external :: ExternalWorkdirServerFrame",
|
||||||
|
server_to_client_frame: "workdir :: external :: ExternalWorkdirProviderFrame",
|
||||||
|
},
|
||||||
|
..external
|
||||||
|
};
|
||||||
|
assert_ne!(
|
||||||
|
operation_fingerprint(&external),
|
||||||
|
operation_fingerprint(&swapped),
|
||||||
|
"directional frame authority must participate in the operation fingerprint"
|
||||||
|
);
|
||||||
|
|
||||||
|
let openapi = canonical_openapi_document()
|
||||||
|
.expect("canonical OpenAPI contract must build")
|
||||||
|
.as_value()
|
||||||
|
.clone();
|
||||||
|
for path in [
|
||||||
|
"/api/w/{workspace_id}/protocol/ws",
|
||||||
|
"/api/w/{workspace_id}/external-workdir-grants/{grant_id}/provider",
|
||||||
|
"/api/runtimes/{runtime_id}/workers/{worker_id}/protocol/ws",
|
||||||
|
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/protocol/ws",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
openapi["paths"][path].is_null(),
|
||||||
|
"WebSocket route {path} must not be projected into OpenAPI"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runtime_config_response_round_trips_the_complete_config_bundle_shape() {
|
fn runtime_config_response_round_trips_the_complete_config_bundle_shape() {
|
||||||
let fixture = serde_json::json!({
|
let fixture = serde_json::json!({
|
||||||
@@ -11006,7 +11273,10 @@ mod openapi_artifact_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let operations = ServerApiMetadata::OPERATIONS;
|
let operations = ServerApiMetadata::OPERATIONS
|
||||||
|
.iter()
|
||||||
|
.filter(|operation| matches!(operation.transport, api_macros::TransportMetadata::Http))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
documented.len() + SIGNED_INTERNAL.len() + OTHER_OPENAPI_EXCLUDED.len(),
|
documented.len() + SIGNED_INTERNAL.len() + OTHER_OPENAPI_EXCLUDED.len(),
|
||||||
operations.len(),
|
operations.len(),
|
||||||
|
|||||||
@@ -8311,28 +8311,34 @@ impl server_api::ServerApi for ServerApiContractService {
|
|||||||
fn build_inner_router(api: WorkspaceApi) -> Router {
|
fn build_inner_router(api: WorkspaceApi) -> Router {
|
||||||
let contract_service = ServerApiContractService::Workspace(api.clone());
|
let contract_service = ServerApiContractService::Workspace(api.clone());
|
||||||
let auth = generated_auth_contract_router(contract_service.clone());
|
let auth = generated_auth_contract_router(contract_service.clone());
|
||||||
let workspace = Router::new()
|
let workspace = Router::new();
|
||||||
// WebSocket provider transport is intentionally outside the generated
|
let workspace = server_api::server_support::websocket_route::<
|
||||||
// JSON REST contract.
|
server_api::server_api_operations::ExternalWorkdirProviderWebSocket,
|
||||||
.route(
|
_,
|
||||||
"/api/w/{workspace_id}/external-workdir-grants/{grant_id}/provider",
|
_,
|
||||||
get(scoped_external_workdir_provider_ws),
|
_,
|
||||||
)
|
>(workspace, scoped_external_workdir_provider_ws);
|
||||||
.route(
|
let workspace = server_api::server_support::websocket_route::<
|
||||||
"/api/w/{workspace_id}/protocol/ws",
|
server_api::server_api_operations::WorkspaceProtocolWebSocket,
|
||||||
get(scoped_workspace_protocol_ws),
|
_,
|
||||||
)
|
_,
|
||||||
.route(
|
_,
|
||||||
"/api/runtimes/{runtime_id}/workers/{worker_id}/protocol/ws",
|
>(workspace, scoped_workspace_protocol_ws);
|
||||||
get(worker_protocol_ws),
|
let workspace = server_api::server_support::websocket_route::<
|
||||||
)
|
server_api::server_api_operations::WorkerProtocolWebSocketAlias,
|
||||||
.route(
|
_,
|
||||||
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/protocol/ws",
|
_,
|
||||||
get(scoped_worker_protocol_ws),
|
_,
|
||||||
)
|
>(workspace, worker_protocol_ws);
|
||||||
.fallback(get(static_or_spa_fallback))
|
let workspace = server_api::server_support::websocket_route::<
|
||||||
.with_state(api)
|
server_api::server_api_operations::WorkspaceWorkerProtocolWebSocket,
|
||||||
.merge(generated_workspace_contract_router(contract_service));
|
_,
|
||||||
|
_,
|
||||||
|
_,
|
||||||
|
>(workspace, scoped_worker_protocol_ws)
|
||||||
|
.fallback(get(static_or_spa_fallback))
|
||||||
|
.with_state(api)
|
||||||
|
.merge(generated_workspace_contract_router(contract_service));
|
||||||
auth.merge(workspace)
|
auth.merge(workspace)
|
||||||
.layer(middleware::from_fn(log_failed_api_response))
|
.layer(middleware::from_fn(log_failed_api_response))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13893,7 +13893,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "Yoi Server API",
|
"title": "Yoi Server API",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"x-yoi-source-digest": "sha256:2463eb461342aceb84c87e7aa6bdcf73e28e3866349a555bd4a0d795db671a3f"
|
"x-yoi-source-digest": "sha256:7a9d11f50cc41eca55a6dded4b8f55aa6d23b1beb2dc69b0a9b6c8349efc894f"
|
||||||
},
|
},
|
||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"paths": {
|
"paths": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:2463eb461342aceb84c87e7aa6bdcf73e28e3866349a555bd4a0d795db671a3f)
|
// Input contract: 0.1.0 (sha256:7a9d11f50cc41eca55a6dded4b8f55aa6d23b1beb2dc69b0a9b6c8349efc894f)
|
||||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:2463eb461342aceb84c87e7aa6bdcf73e28e3866349a555bd4a0d795db671a3f)
|
// Input contract: 0.1.0 (sha256:7a9d11f50cc41eca55a6dded4b8f55aa6d23b1beb2dc69b0a9b6c8349efc894f)
|
||||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:2463eb461342aceb84c87e7aa6bdcf73e28e3866349a555bd4a0d795db671a3f)
|
// Input contract: 0.1.0 (sha256:7a9d11f50cc41eca55a6dded4b8f55aa6d23b1beb2dc69b0a9b6c8349efc894f)
|
||||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:2463eb461342aceb84c87e7aa6bdcf73e28e3866349a555bd4a0d795db671a3f)
|
// Input contract: 0.1.0 (sha256:7a9d11f50cc41eca55a6dded4b8f55aa6d23b1beb2dc69b0a9b6c8349efc894f)
|
||||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
|
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:2463eb461342aceb84c87e7aa6bdcf73e28e3866349a555bd4a0d795db671a3f)
|
// Input contract: 0.1.0 (sha256:7a9d11f50cc41eca55a6dded4b8f55aa6d23b1beb2dc69b0a9b6c8349efc894f)
|
||||||
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/workdir-api.ts
|
// Output: web/workspace/src/lib/generated/workdir-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:2463eb461342aceb84c87e7aa6bdcf73e28e3866349a555bd4a0d795db671a3f)
|
// Input contract: 0.1.0 (sha256:7a9d11f50cc41eca55a6dded4b8f55aa6d23b1beb2dc69b0a9b6c8349efc894f)
|
||||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||||
|
|||||||
Reference in New Issue
Block a user