diff --git a/crates/api-macros-impl/src/lib.rs b/crates/api-macros-impl/src/lib.rs index 5cfbc1d4e..b6f302e42 100644 --- a/crates/api-macros-impl/src/lib.rs +++ b/crates/api-macros-impl/src/lib.rs @@ -105,6 +105,7 @@ struct RouteArgs { operation_id: Option, status: Option, alternate_status: Option, + responses: Vec, error_status: Option, additional_error_statuses: Vec, bearer_auth: Option, @@ -113,6 +114,86 @@ struct RouteArgs { openapi: Option, } +struct DeclaredResponseHeader { + name: LitStr, + field_ident: Ident, + ty: Type, +} + +struct DeclaredResponse { + status: LitInt, + body: Option, + headers: Vec, +} + +impl Parse for DeclaredResponse { + fn parse(input: ParseStream<'_>) -> syn::Result { + let content; + syn::parenthesized!(content in input); + let mut status = None; + let mut body = None; + let mut headers = Vec::new(); + while !content.is_empty() { + let key: Ident = content.parse()?; + content.parse::()?; + match key.to_string().as_str() { + "status" => set_once( + &mut status, + content.parse::()?, + &key, + "response status", + )?, + "body" => set_once(&mut body, content.parse::()?, &key, "response body")?, + "headers" => { + if !headers.is_empty() { + return Err(syn::Error::new( + key.span(), + "duplicate `headers` response option", + )); + } + let header_content; + syn::bracketed!(header_content in content); + while !header_content.is_empty() { + let pair; + syn::parenthesized!(pair in header_content); + let name = pair.parse::()?; + pair.parse::()?; + let ty = pair.parse::()?; + if !pair.is_empty() { + return Err(pair.error( + "response header entries contain exactly a name and Rust type", + )); + } + let field_ident = response_header_field_ident(&name)?; + headers.push(DeclaredResponseHeader { + name, + field_ident, + ty, + }); + if !header_content.is_empty() { + header_content.parse::()?; + } + } + } + _ => { + return Err(syn::Error::new( + key.span(), + "unsupported response option; expected status, body, or headers", + )); + } + } + if !content.is_empty() { + content.parse::()?; + } + } + Ok(Self { + status: status.ok_or_else(|| input.error("declared response requires `status`"))?, + body, + headers, + }) + } +} + impl Parse for RouteArgs { fn parse(input: ParseStream<'_>) -> syn::Result { let path = input.parse()?; @@ -121,6 +202,7 @@ impl Parse for RouteArgs { operation_id: None, status: None, alternate_status: None, + responses: Vec::new(), error_status: None, additional_error_statuses: Vec::new(), bearer_auth: None, @@ -150,6 +232,26 @@ impl Parse for RouteArgs { &key, "alternate_status", )?, + "responses" => { + if !result.responses.is_empty() { + return Err(syn::Error::new( + key.span(), + "duplicate `responses` route option", + )); + } + let content; + syn::bracketed!(content in input); + result.responses = + Punctuated::::parse_terminated(&content)? + .into_iter() + .collect(); + if result.responses.is_empty() { + return Err(syn::Error::new( + key.span(), + "`responses` must declare at least one successful response", + )); + } + } "error_status" => set_once( &mut result.error_status, input.parse::()?, @@ -199,7 +301,7 @@ impl Parse for RouteArgs { _ => { return Err(syn::Error::new( key.span(), - "unsupported route option; expected operation_id, status, or error_status", + "unsupported route option; expected operation_id, status, alternate_status, responses, error_status, additional_error_statuses, bearer_auth, browser_auth, normalize_body_errors, or openapi", )); } } @@ -263,6 +365,18 @@ struct RequestBody { wire_kind: RequestWireKind, } +struct ResponseHeader { + wire_name: String, + field_ident: Ident, + ty: Type, +} + +struct SuccessResponse { + status: u16, + body: Option, + headers: Vec, +} + struct Operation { method_ident: Ident, marker_ident: Ident, @@ -272,6 +386,8 @@ struct Operation { parameters: Vec, request_body: Option, response_body: Option, + success_responses: Vec, + declared_responses: bool, error_body: Option, fallible: bool, response_status: u16, @@ -461,31 +577,130 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result { validate_named_body_type(ty, "error response")?; } + let declared_responses = !route.responses.is_empty(); + if declared_responses && (route.status.is_some() || route.alternate_status.is_some()) { + return Err(syn::Error::new_spanned( + &route.path, + "`responses` cannot be combined with `status` or `alternate_status`", + )); + } + if declared_responses && response_body.is_none() { + return Err(syn::Error::new_spanned( + &method.sig.output, + "declared responses require the generated success result type", + )); + } + + let mut success_responses = Vec::new(); + if declared_responses { + let mut statuses = BTreeSet::new(); + for response in route.responses { + let status = response.status.base10_parse::().map_err(|_| { + syn::Error::new( + response.status.span(), + "HTTP status must be an unsigned 16-bit integer", + ) + })?; + if !((200..=299).contains(&status) || status == 304) { + return Err(syn::Error::new( + response.status.span(), + "declared success response status must be between 200 and 299, or exactly 304", + )); + } + if !statuses.insert(status) { + return Err(syn::Error::new( + response.status.span(), + "declared success response statuses must be distinct", + )); + } + if response.body.is_some() && matches!(status, 204 | 205 | 304) { + return Err(syn::Error::new( + response.status.span(), + format!("status {status} cannot have a response body"), + )); + } + if http_method == Method::Head && response.body.is_some() { + return Err(syn::Error::new_spanned( + response.body, + "HEAD operations cannot declare a response body", + )); + } + if let Some(body) = response.body.as_ref() { + validate_named_body_type(body, "response")?; + } + let mut header_names = BTreeSet::new(); + let mut field_names = BTreeSet::new(); + let mut headers = Vec::new(); + for header in response.headers { + let wire_name = header.name.value().to_ascii_lowercase(); + validate_response_header_name(&wire_name, &header.name)?; + if !header_names.insert(wire_name.clone()) { + return Err(syn::Error::new_spanned( + header.name, + "duplicate response header name within one response", + )); + } + if !field_names.insert(header.field_ident.to_string()) { + return Err(syn::Error::new_spanned( + header.name, + "response header names produce duplicate Rust field names", + )); + } + headers.push(ResponseHeader { + wire_name, + field_ident: header.field_ident, + ty: header.ty, + }); + } + success_responses.push(SuccessResponse { + status, + body: response.body, + headers, + }); + } + } + let response_status = parse_status(route.status.as_ref(), "status")? .unwrap_or(if response_body.is_some() { 200 } else { 204 }); - if !(200..=299).contains(&response_status) { - return Err(syn::Error::new( - route - .status - .as_ref() - .map_or(route.path.span(), Spanned::span), - "status must be a success status between 200 and 299", - )); - } - let alternate_status = parse_status(route.alternate_status.as_ref(), "alternate_status")?; - if let Some(status) = alternate_status - && (!(200..=299).contains(&status) || status == response_status) - { - return Err(syn::Error::new_spanned( - route.alternate_status, - "alternate_status must be a distinct success status between 200 and 299", - )); - } - if alternate_status.is_some() && response_body.is_none() { - return Err(syn::Error::new_spanned( - route.alternate_status, - "alternate_status requires a response body implementing HttpSuccess", - )); + let mut alternate_status = parse_status(route.alternate_status.as_ref(), "alternate_status")?; + if !declared_responses { + if !(200..=299).contains(&response_status) { + return Err(syn::Error::new( + route + .status + .as_ref() + .map_or(route.path.span(), Spanned::span), + "status must be a success status between 200 and 299", + )); + } + if let Some(status) = alternate_status + && (!(200..=299).contains(&status) || status == response_status) + { + return Err(syn::Error::new_spanned( + route.alternate_status, + "alternate_status must be a distinct success status between 200 and 299", + )); + } + if alternate_status.is_some() && response_body.is_none() { + return Err(syn::Error::new_spanned( + route.alternate_status, + "alternate_status requires a response body implementing HttpSuccess", + )); + } + success_responses.push(SuccessResponse { + status: response_status, + body: response_body.clone(), + headers: Vec::new(), + }); + if let Some(status) = alternate_status { + success_responses.push(SuccessResponse { + status, + body: response_body.clone(), + headers: Vec::new(), + }); + } + } else { + alternate_status = None; } let error_status = parse_status(route.error_status.as_ref(), "error_status")?; if error_status.is_some() && error_body.is_none() { @@ -532,13 +747,13 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result { )); } - if http_method == Method::Head && response_body.is_some() { + if !declared_responses && http_method == Method::Head && response_body.is_some() { return Err(syn::Error::new_spanned( &method.sig.output, "HEAD operations cannot declare a response body", )); } - if matches!(response_status, 204 | 205) && response_body.is_some() { + if !declared_responses && matches!(response_status, 204 | 205) && response_body.is_some() { return Err(syn::Error::new_spanned( &method.sig.output, format!("status {response_status} cannot have a response body"), @@ -702,6 +917,8 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result { parameters, request_body: body_type, response_body, + success_responses, + declared_responses, error_body, fallible, response_status, @@ -876,6 +1093,71 @@ fn validate_operation_id(operation_id: &str, source: &impl Spanned) -> syn::Resu Ok(()) } +fn response_header_field_ident(name: &LitStr) -> syn::Result { + let value = name.value().to_ascii_lowercase(); + validate_response_header_name(&value, name)?; + let field = value + .bytes() + .map(|byte| { + if byte.is_ascii_alphanumeric() { + byte as char + } else { + '_' + } + }) + .collect::(); + Ok(format_ident!("header_{}", field, span = name.span())) +} + +fn validate_response_header_name(name: &str, source: &impl Spanned) -> syn::Result<()> { + let valid = !name.is_empty() + && name.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'.' + | b'^' + | b'_' + | b'`' + | b'|' + | b'~' + ) + }); + if !valid { + return Err(syn::Error::new( + source.span(), + "response header name must be a valid HTTP field name", + )); + } + if matches!( + name, + "connection" + | "content-length" + | "content-type" + | "keep-alive" + | "proxy-authenticate" + | "proxy-authorization" + | "te" + | "trailer" + | "transfer-encoding" + | "upgrade" + ) { + return Err(syn::Error::new( + source.span(), + "response header is transport-controlled and cannot be declared", + )); + } + Ok(()) +} + fn parse_status(value: Option<&LitInt>, name: &str) -> syn::Result> { let Some(value) = value else { return Ok(None); @@ -932,6 +1214,15 @@ fn is_unit(ty: &Type) -> bool { matches!(ty, Type::Tuple(tuple) if tuple.elems.is_empty()) } +fn is_option_type(ty: &Type) -> bool { + matches!( + ty, + Type::Path(path) + if path.qself.is_none() + && path.path.segments.last().is_some_and(|segment| segment.ident == "Option") + ) +} + fn validate_named_body_type(ty: &Type, kind: &str) -> syn::Result<()> { let Type::Path(path) = ty else { return Err(syn::Error::new_spanned( @@ -1083,6 +1374,11 @@ fn reqwest_adapter_tokens( let visibility = &api.item.vis; let client_ident = format_ident!("{}Client", ident_text(trait_ident)); let builder_ident = format_ident!("{}ClientBuilder", ident_text(trait_ident)); + let responses_module_ident = format_ident!( + "{}_responses", + to_snake_case(trait_ident), + span = trait_ident.span() + ); let methods = api.operations.iter().map(|operation| { let method_ident = &operation.method_ident; let arguments = operation.parameters.iter().filter_map(|parameter| { @@ -1114,10 +1410,17 @@ fn reqwest_adapter_tokens( } let ident = ¶meter.rust_ident; let name = ¶meter.wire_name; - Some(quote! { - #api_crate::reqwest::insert_header(&mut __headers, #name, &#ident) - .map_err(#api_crate::reqwest::ClientError::from)?; - }) + if is_option_type(¶meter.ty) { + Some(quote! { + #api_crate::reqwest::insert_optional_header(&mut __headers, #name, &#ident) + .map_err(#api_crate::reqwest::ClientError::from)?; + }) + } else { + Some(quote! { + #api_crate::reqwest::insert_header(&mut __headers, #name, &#ident) + .map_err(#api_crate::reqwest::ClientError::from)?; + }) + } }); let body = operation .parameters @@ -1140,11 +1443,7 @@ fn reqwest_adapter_tokens( }) .unwrap_or_else(|| quote!(::core::option::Option::None)); let method = reqwest_method_tokens(operation.method, api_crate); - let response_status = operation.response_status; - let accepted_status = operation - .alternate_status - .map(|alternate| quote!(__actual == #response_status || __actual == #alternate)) - .unwrap_or_else(|| quote!(__actual == #response_status)); + let response_status = operation.success_responses[0].status; let response_type = operation .response_body .as_ref() @@ -1155,17 +1454,76 @@ fn reqwest_adapter_tokens( .as_ref() .map(|ty| quote!(#ty)) .unwrap_or_else(|| quote!(#api_crate::NoBody)); - let success = if let Some(response) = operation.response_body.as_ref() { + let success_dispatch = if operation.declared_responses { + let response_ident = &operation.marker_ident; + let arms = operation.success_responses.iter().map(|response| { + let status = response.status; + let variant_ident = format_ident!("Status{}", status); + let header_parsers = response.headers.iter().map(|header| { + let field = &header.field_ident; + let ty = &header.ty; + let name = &header.wire_name; + quote! { + let #field = __response + .parse_header::<#ty>(#name) + .map_err(#api_crate::reqwest::ClientError::from)?; + } + }); + let body_parser = match response.body.as_ref() { + Some(ty) => quote! { + let body = __response + .decode_json::<#ty>(#api_crate::reqwest::DecodeKind::Success) + .map_err(#api_crate::reqwest::ClientError::from)?; + }, + None => quote! { + __response + .require_empty() + .map_err(#api_crate::reqwest::ClientError::from)?; + }, + }; + let body_field = response.body.as_ref().map(|_| quote!(body,)); + let header_fields = response.headers.iter().map(|header| &header.field_ident); + quote! { + #status => { + #(#header_parsers)* + #body_parser + return ::core::result::Result::Ok( + #responses_module_ident::#response_ident::#variant_ident { + #body_field + #(#header_fields),* + }, + ); + } + } + }); quote! { - return __response - .decode_json::<#response>(#api_crate::reqwest::DecodeKind::Success) - .map_err(#api_crate::reqwest::ClientError::from); + match __actual { + #(#arms)* + _ => {} + } } } else { + let accepted_status = operation + .alternate_status + .map(|alternate| quote!(__actual == #response_status || __actual == #alternate)) + .unwrap_or_else(|| quote!(__actual == #response_status)); + let success = if let Some(response) = operation.response_body.as_ref() { + quote! { + return __response + .decode_json::<#response>(#api_crate::reqwest::DecodeKind::Success) + .map_err(#api_crate::reqwest::ClientError::from); + } + } else { + quote! { + return __response + .require_empty() + .map_err(#api_crate::reqwest::ClientError::from); + } + }; quote! { - return __response - .require_empty() - .map_err(#api_crate::reqwest::ClientError::from); + if #accepted_status { + #success + } } }; let public_error = match operation.error_body.as_ref() { @@ -1202,9 +1560,7 @@ fn reqwest_adapter_tokens( .await .map_err(#api_crate::reqwest::ClientError::from)?; let __actual = __response.status().as_u16(); - if #accepted_status { - #success - } + #success_dispatch #public_error ::core::result::Result::Err(#api_crate::reqwest::ClientError::from( #api_crate::reqwest::ClientFailure::UnexpectedStatus { @@ -1355,6 +1711,11 @@ fn axum_adapter_tokens( let visibility = &api.item.vis; let adapter_ident = format_ident!("{}Axum", ident_text(trait_ident)); let module_ident = format_ident!("{}_axum", to_snake_case(trait_ident)); + let responses_module_ident = format_ident!( + "{}_responses", + to_snake_case(trait_ident), + span = trait_ident.span() + ); let route_functions: Vec<_> = api.operations.iter().map(|operation| { let method_ident = &operation.method_ident; let handler_ident = format_ident!("{}_handler", ident_text(method_ident)); @@ -1391,8 +1752,13 @@ fn axum_adapter_tokens( let ident = ¶meter.rust_ident; let ty = ¶meter.ty; let name = ¶meter.wire_name; + let parser = if is_option_type(ty) { + quote!(#api_crate::axum::parse_optional_header) + } else { + quote!(#api_crate::axum::parse_header) + }; Some(quote! { - let #ident: #ty = match #api_crate::axum::parse_header(&__headers, #name) { + let #ident: #ty = match #parser(&__headers, #name) { Ok(value) => value, Err(status) => return #api_crate::axum::rejection(status), }; @@ -1488,7 +1854,50 @@ fn axum_adapter_tokens( .unwrap_or((None, None)); let call_arguments = operation.parameters.iter().map(|parameter| ¶meter.rust_ident); let response_status = operation.response_status; - let success = if let Some(alternate_status) = operation.alternate_status { + let success = if operation.declared_responses { + let response_ident = &operation.marker_ident; + let arms = operation.success_responses.iter().map(|response| { + let status = response.status; + let variant_ident = format_ident!("Status{}", status); + let body_pattern = response.body.as_ref().map(|_| quote!(body,)); + let header_fields = response.headers.iter().map(|header| &header.field_ident); + let base_response = if response.body.is_some() { + quote!(#api_crate::axum::json_response(#api_crate::axum::status(#status), body)) + } else { + quote!(#api_crate::axum::empty_response(#api_crate::axum::status(#status))) + }; + let header_insertions = response.headers.iter().map(|header| { + let field = &header.field_ident; + let name = &header.wire_name; + quote! { + if #api_crate::axum::insert_response_header( + __response.headers_mut(), + #name, + &#field, + ).is_err() { + return #api_crate::axum::empty_response( + #api_crate::axum::framework::StatusCode::INTERNAL_SERVER_ERROR, + ); + } + } + }); + quote! { + #responses_module_ident::#response_ident::#variant_ident { + #body_pattern + #(#header_fields),* + } => { + let mut __response = #base_response; + #(#header_insertions)* + __response + } + } + }); + quote! { + match value { + #(#arms),* + } + } + } else if let Some(alternate_status) = operation.alternate_status { quote!({ let status = #api_crate::HttpSuccess::status_code(&value); if status == #response_status || status == #alternate_status { @@ -1628,23 +2037,31 @@ fn openapi_adapter_tokens( operation.binary_request_body()?; }, }); - let status = operation.response_status; - let response = match &operation.response_body { - Some(ty) => quote! { - operation.response::<#ty>(#status, "application/json", "Successful response")?; - }, - None => quote! { - operation.empty_response(#status, "Successful response")?; - }, - }; - let alternate_response = operation.alternate_status.map(|status| { - match operation.response_body.as_ref() { + let success_responses = operation.success_responses.iter().enumerate().map(|(index, response)| { + let status = response.status; + let description = if index == 0 { + "Successful response" + } else { + "Alternate successful response" + }; + let body = match response.body.as_ref() { Some(ty) => quote! { - operation.response::<#ty>(#status, "application/json", "Alternate successful response")?; + operation.response::<#ty>(#status, "application/json", #description)?; }, None => quote! { - operation.empty_response(#status, "Alternate successful response")?; + operation.empty_response(#status, #description)?; }, + }; + let headers = response.headers.iter().map(|header| { + let ty = &header.ty; + let name = &header.wire_name; + quote! { + operation.response_header::<#ty>(#status, #name)?; + } + }); + quote! { + #body + #(#headers)* } }); let error = operation @@ -1684,8 +2101,7 @@ fn openapi_adapter_tokens( #bearer_auth #browser_auth #request - #response - #alternate_response + #(#success_responses)* #error #(#additional_errors)* operation.finish()?; @@ -1713,6 +2129,56 @@ fn expand_api(api: ApiDefinition) -> syn::Result { let reqwest_adapter = reqwest_adapter_tokens(&api, &api_crate); let axum_adapter = axum_adapter_tokens(&api, &api_crate); let openapi_adapter = openapi_adapter_tokens(&api, &api_crate); + let responses_module_ident = format_ident!( + "{}_responses", + to_snake_case(&api.item.ident), + span = api.item.ident.span() + ); + let response_items: Vec<_> = api + .operations + .iter() + .filter(|operation| operation.declared_responses) + .map(|operation| { + let response_ident = &operation.marker_ident; + let variants = operation.success_responses.iter().map(|response| { + let variant_ident = format_ident!("Status{}", response.status); + let body = response.body.as_ref().map(|ty| quote!(body: #ty,)); + let headers = response.headers.iter().map(|header| { + let field = &header.field_ident; + let ty = &header.ty; + quote!(#field: #ty,) + }); + quote! { + #variant_ident { + #body + #(#headers)* + } + } + }); + let debug_arms = operation.success_responses.iter().map(|response| { + let variant_ident = format_ident!("Status{}", response.status); + quote! { + Self::#variant_ident { .. } => formatter + .debug_struct(stringify!(#variant_ident)) + .finish_non_exhaustive() + } + }); + quote! { + #[doc = "Typed status/body/header result for one declared-success operation."] + pub enum #response_ident { + #(#variants),* + } + + impl ::core::fmt::Debug for #response_ident { + fn fmt(&self, formatter: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + match self { + #(#debug_arms),* + } + } + } + } + }) + .collect(); let item = api.item; let metadata_ident = api.metadata_ident; let module_ident = api.operations_module_ident; @@ -1797,6 +2263,13 @@ fn expand_api(api: ApiDefinition) -> syn::Result { #(#marker_items)* } + #[doc = "Typed success results generated for operations with declared responses."] + pub mod #responses_module_ident { + #[allow(unused_imports)] + use super::*; + #(#response_items)* + } + #(#operation_impls)* #reqwest_adapter @@ -1817,23 +2290,44 @@ fn metadata_tokens( Some(RequestWireKind::Binary) => quote!(#api_crate::WireKind::Binary), None => quote!(#api_crate::WireKind::Empty), }; - let response_kind = if operation.response_body.is_some() { - quote!(#api_crate::WireKind::Json) - } else { - quote!(#api_crate::WireKind::Empty) - }; - let response_status = operation.response_status; - let error = match operation.error_status { - Some(status) => quote! { - ::core::option::Option::Some(#api_crate::ResponseMetadata { + let success_responses = operation.success_responses.iter().map(|response| { + let status = response.status; + let wire_kind = if response.body.is_some() { + quote!(#api_crate::WireKind::Json) + } else { + quote!(#api_crate::WireKind::Empty) + }; + let headers = response.headers.iter().map(|header| { + let wire_name = &header.wire_name; + let ty = &header.ty; + quote! { + #api_crate::ResponseHeaderMetadata { + wire_name: #wire_name, + rust_type: stringify!(#ty), + } + } + }); + quote! { + #api_crate::ResponseMetadata { status: #status, - body: #api_crate::BodyMetadata { - wire_kind: #api_crate::WireKind::Json, - }, - }) - }, - None => quote!(::core::option::Option::None), - }; + body: #api_crate::BodyMetadata { wire_kind: #wire_kind }, + headers: &[#(#headers),*], + } + } + }); + let error_statuses = operation + .error_status + .into_iter() + .chain(operation.additional_error_statuses.iter().copied()); + let error_responses = error_statuses.map(|status| { + quote! { + #api_crate::ResponseMetadata { + status: #status, + body: #api_crate::BodyMetadata { wire_kind: #api_crate::WireKind::Json }, + headers: &[], + } + } + }); let parameters = operation.parameters.iter().filter_map(|parameter| { if matches!(parameter.location, Location::Extension) { return None; @@ -1857,11 +2351,8 @@ fn metadata_tokens( path: #path, parameters: &[#(#parameters),*], request_body: #api_crate::BodyMetadata { wire_kind: #request_kind }, - response: #api_crate::ResponseMetadata { - status: #response_status, - body: #api_crate::BodyMetadata { wire_kind: #response_kind }, - }, - error_response: #error, + success_responses: &[#(#success_responses),*], + error_responses: &[#(#error_responses),*], } } } diff --git a/crates/api-macros/src/axum.rs b/crates/api-macros/src/axum.rs index 799a072da..f577245e2 100644 --- a/crates/api-macros/src/axum.rs +++ b/crates/api-macros/src/axum.rs @@ -5,7 +5,7 @@ //! other application state remain explicit outer Axum layers. Each operation is also exposed as a //! separate router so route-specific layers can be applied before routers are merged. -use std::str::FromStr; +use std::{fmt, str::FromStr}; use axum::{ Json, @@ -28,6 +28,48 @@ where .map_err(|_| StatusCode::BAD_REQUEST) } +pub fn parse_optional_header( + headers: &HeaderMap, + name: &'static str, +) -> Result, StatusCode> +where + T: FromStr, +{ + headers + .get(name) + .map(|value| { + value + .to_str() + .map_err(|_| StatusCode::BAD_REQUEST)? + .parse() + .map_err(|_| StatusCode::BAD_REQUEST) + }) + .transpose() +} + +/// Opaque failure to encode one declared response header. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ResponseHeaderError; + +impl fmt::Display for ResponseHeaderError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("declared response header encoding failed") + } +} + +impl std::error::Error for ResponseHeaderError {} + +/// Insert one typed declared response header without retaining its value in an error. +pub fn insert_response_header( + headers: &mut HeaderMap, + name: &'static str, + value: &T, +) -> Result<(), ResponseHeaderError> { + let value = value.to_string().parse().map_err(|_| ResponseHeaderError)?; + headers.insert(name, value); + Ok(()) +} + /// Serialize a typed JSON response with the contract status. pub fn json_response(status: StatusCode, value: T) -> Response { (status, Json(value)).into_response() @@ -52,7 +94,7 @@ pub fn status(code: u16) -> StatusCode { pub mod framework { pub use axum::{ Json, Router, - body::Bytes, + body::{Body, Bytes}, extract::{ DefaultBodyLimit, Extension, Path, Query, State, rejection::{BytesRejection, JsonRejection}, @@ -63,3 +105,21 @@ pub mod framework { serve, }; } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn response_header_encoding_failure_is_bounded_and_value_safe() { + let mut headers = HeaderMap::new(); + let error = insert_response_header(&mut headers, "set-cookie", &"secret\ninvalid") + .expect_err("invalid header bytes must fail"); + assert_eq!( + error.to_string(), + "declared response header encoding failed" + ); + assert!(!format!("{error:?}").contains("secret")); + assert!(headers.is_empty()); + } +} diff --git a/crates/api-macros/src/lib.rs b/crates/api-macros/src/lib.rs index 45f4ec51c..c9596b190 100644 --- a/crates/api-macros/src/lib.rs +++ b/crates/api-macros/src/lib.rs @@ -39,7 +39,11 @@ //! method name but should be set explicitly for contracts which must remain stable while Rust //! names evolve. Success status defaults to `200`, except an empty (`()`) response defaults to //! `204`. `alternate_status` opts a JSON response into [`HttpSuccess`] when two successful HTTP -//! outcomes share one schema. A public error inferred from `Result` defaults to status `400`; +//! outcomes share one schema. Operations whose successful statuses have different body or header +//! shapes use `responses = [(status = 200, body = Widget, headers = [("etag", String)]), +//! (status = 304, headers = [("etag", String)])]`; the macro emits a typed result enum in the +//! `_responses` module. Only explicit body-less `304` declarations extend success +//! beyond 2xx. A public error inferred from `Result` defaults to status `400`; //! `additional_error_statuses` publishes the same typed error schema for other declared outcomes. //! `bearer_auth = true` and `browser_auth = true` attach standard bearer and browser-session cookie //! security schemes. Body operations may opt into typed Axum rejection normalization with @@ -226,11 +230,21 @@ pub struct BodyMetadata { pub wire_kind: WireKind, } +/// Stable metadata for one explicitly declared response header. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct ResponseHeaderMetadata { + /// Canonical wire name declared in the API contract. + pub wire_name: &'static str, + /// Rust type spelling declared alongside the wire name. + pub rust_type: &'static str, +} + /// Stable metadata for one HTTP response. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub struct ResponseMetadata { pub status: u16, pub body: BodyMetadata, + pub headers: &'static [ResponseHeaderMetadata], } /// Complete transport metadata for one operation. @@ -241,8 +255,10 @@ pub struct OperationMetadata { pub path: &'static str, pub parameters: &'static [ParameterMetadata], pub request_body: BodyMetadata, - pub response: ResponseMetadata, - pub error_response: Option, + /// Every declared successful response, including status-specific body and header shape. + pub success_responses: &'static [ResponseMetadata], + /// Every declared public-error status. Error responses currently have no declared headers. + pub error_responses: &'static [ResponseMetadata], } /// Deterministic operation inventory emitted for an `#[api]` trait. @@ -256,7 +272,7 @@ pub trait Operation { type Parameters; /// Typed JSON or binary request body, or [`NoBody`]. type RequestBody; - /// JSON success response body type, or [`NoBody`]. + /// JSON success response body type, generated declared-response result, or [`NoBody`]. type ResponseBody; /// JSON public error body type, or [`NoBody`]. type ErrorBody; diff --git a/crates/api-macros/src/openapi.rs b/crates/api-macros/src/openapi.rs index 9517bed97..81b6a096a 100644 --- a/crates/api-macros/src/openapi.rs +++ b/crates/api-macros/src/openapi.rs @@ -427,6 +427,42 @@ impl OpenApiOperation<'_> { self.insert_response(status, json!({ "description": description })) } + pub fn response_header( + &mut self, + status: u16, + name: &'static str, + ) -> Result<(), OpenApiError> { + let schema = self.parent.schema_ref::()?; + let response = self + .responses + .get_mut(&status.to_string()) + .and_then(Value::as_object_mut) + .ok_or_else(|| { + OpenApiError::InvalidContract(format!( + "response header `{name}` precedes status `{status}` for `{}`", + self.operation_id + )) + })?; + let headers = response + .entry("headers") + .or_insert_with(|| Value::Object(Map::new())) + .as_object_mut() + .ok_or_else(|| { + OpenApiError::InvalidContract(format!( + "response headers for status `{status}` are not an object for `{}`", + self.operation_id + )) + })?; + if headers.contains_key(name) { + return Err(OpenApiError::InvalidContract(format!( + "duplicate response header `{name}` for status `{status}` on `{}`", + self.operation_id + ))); + } + headers.insert(name.to_owned(), json!({ "schema": schema })); + Ok(()) + } + fn insert_response(&mut self, status: u16, response: Value) -> Result<(), OpenApiError> { if !(100..=599).contains(&status) { return Err(OpenApiError::InvalidContract(format!( diff --git a/crates/api-macros/src/reqwest.rs b/crates/api-macros/src/reqwest.rs index e9a84c354..2a93c984a 100644 --- a/crates/api-macros/src/reqwest.rs +++ b/crates/api-macros/src/reqwest.rs @@ -399,6 +399,7 @@ impl ClientCore { } })?; let status = response.status(); + let headers = response.headers().clone(); let mut stream = response.bytes_stream(); let mut bytes = Vec::new(); while let Some(chunk) = stream.next().await { @@ -421,7 +422,11 @@ impl ClientCore { } bytes.extend_from_slice(&chunk); } - Ok(ReceivedResponse { status, bytes }) + Ok(ReceivedResponse { + status, + headers, + bytes, + }) } } @@ -453,6 +458,18 @@ pub fn insert_header( Ok(()) } +/// Insert an optional string-form header value when present. +pub fn insert_optional_header( + headers: &mut HeaderMap, + name: &str, + value: &Option, +) -> Result<(), ClientFailure> { + match value { + Some(value) => insert_header(headers, name, value), + None => Ok(()), + } +} + /// Serialize a JSON request body once. These exact bytes are authorized and transmitted. pub fn encode_json(value: &T) -> Result { serde_json::to_vec(value) @@ -468,6 +485,7 @@ pub fn encode_binary(value: crate::BinaryBody) -> EncodedBody { /// Response received under the configured byte limit. Its content is always redacted in Debug. pub struct ReceivedResponse { status: StatusCode, + headers: HeaderMap, bytes: Vec, } @@ -476,6 +494,24 @@ impl ReceivedResponse { self.status } + pub fn parse_header(&self, name: &'static str) -> Result + where + T: std::str::FromStr, + { + let mut values = self.headers.get_all(name).iter(); + let value = values + .next() + .ok_or(ClientFailure::MissingResponseHeader { name })?; + if values.next().is_some() { + return Err(ClientFailure::DuplicateResponseHeader { name }); + } + value + .to_str() + .map_err(|_| ClientFailure::InvalidResponseHeader { name })? + .parse() + .map_err(|_| ClientFailure::InvalidResponseHeader { name }) + } + pub fn decode_json(&self, kind: DecodeKind) -> Result { serde_json::from_slice(&self.bytes).map_err(|_| ClientFailure::Decode { kind }) } @@ -494,6 +530,7 @@ impl fmt::Debug for ReceivedResponse { formatter .debug_struct("ReceivedResponse") .field("status", &self.status) + .field("headers", &"") .field("bytes", &"") .finish() } @@ -517,6 +554,9 @@ pub enum ClientFailure { UnexpectedStatus { expected: u16, actual: u16 }, ErrorResponseDecode { status: u16 }, Decode { kind: DecodeKind }, + MissingResponseHeader { name: &'static str }, + DuplicateResponseHeader { name: &'static str }, + InvalidResponseHeader { name: &'static str }, UnexpectedBody, } @@ -552,6 +592,18 @@ impl fmt::Display for ClientFailure { Self::Decode { kind: DecodeKind::PublicError, } => formatter.write_str("public error response JSON was malformed"), + Self::MissingResponseHeader { name } => { + write!(formatter, "response header `{name}` was missing") + } + Self::DuplicateResponseHeader { name } => { + write!( + formatter, + "response header `{name}` occurred more than once" + ) + } + Self::InvalidResponseHeader { name } => { + write!(formatter, "response header `{name}` was invalid") + } Self::UnexpectedBody => formatter.write_str("empty response contained a body"), } } @@ -616,3 +668,19 @@ impl std::error::Error for ClientError {} pub mod framework { pub use reqwest::{Client, Method, StatusCode, Url, header}; } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bodyless_response_validation_rejects_retained_bytes_without_exposing_them() { + let response = ReceivedResponse { + status: StatusCode::NOT_MODIFIED, + headers: HeaderMap::new(), + bytes: b"sensitive-body".to_vec(), + }; + assert_eq!(response.require_empty(), Err(ClientFailure::UnexpectedBody)); + assert!(!format!("{response:?}").contains("sensitive-body")); + } +} diff --git a/crates/api-macros/tests/expansion.rs b/crates/api-macros/tests/expansion.rs index 2e770be44..7268e9a32 100644 --- a/crates/api-macros/tests/expansion.rs +++ b/crates/api-macros/tests/expansion.rs @@ -43,6 +43,19 @@ pub trait BinaryApi { async fn upload(&self, #[binary] payload: BinaryBody) -> Widget; } +#[api] +pub trait ConditionalApi { + #[get( + "/conditional", + operation_id = "conditional.get", + responses = [ + (status = 200, body = Widget, headers = [("etag", String), ("cache-control", String)]), + (status = 304, headers = [("etag", String), ("cache-control", String)]) + ] + )] + async fn fetch(&self) -> conditional_api_responses::Fetch; +} + #[api] pub trait RawIdentifierApi { #[get("/type", operation_id = "raw.type")] @@ -94,9 +107,9 @@ fn expansion_exposes_deterministic_metadata_and_type_connections() { assert_eq!(create.method, HttpMethod::Post); assert_eq!(create.path, "/widgets"); assert_eq!(create.request_body.wire_kind, WireKind::Json); - assert_eq!(create.response.status, 201); - assert_eq!(create.response.body.wire_kind, WireKind::Json); - assert_eq!(create.error_response.unwrap().status, 422); + 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[1].location, ParameterLocation::Header); assert_eq!(create.parameters[1].wire_name, "x-request-id"); @@ -111,10 +124,37 @@ fn expansion_exposes_deterministic_metadata_and_type_connections() { fn assert_binary_request>() {} assert_binary_request::(); + let conditional = ::METADATA; + assert_eq!(conditional.success_responses.len(), 2); + assert_eq!(conditional.success_responses[0].status, 200); + assert_eq!( + conditional.success_responses[0].body.wire_kind, + WireKind::Json + ); + assert_eq!(conditional.success_responses[0].headers.len(), 2); + assert_eq!( + conditional.success_responses[0].headers[0].wire_name, + "etag" + ); + assert_eq!( + conditional.success_responses[0].headers[0].rust_type, + "String" + ); + assert_eq!(conditional.success_responses[1].status, 304); + assert_eq!( + conditional.success_responses[1].body.wire_kind, + WireKind::Empty + ); + fn assert_conditional_response< + O: Operation, + >() { + } + assert_conditional_response::(); + let delete = ::METADATA; - assert_eq!(delete.response.status, 204); - assert_eq!(delete.response.body.wire_kind, WireKind::Empty); - assert_eq!(delete.error_response, None); + assert_eq!(delete.success_responses[0].status, 204); + assert_eq!(delete.success_responses[0].body.wire_kind, WireKind::Empty); + assert!(delete.error_responses.is_empty()); fn assert_empty_request>() {} fn assert_empty_response>() {} diff --git a/crates/api-macros/tests/http_adapters.rs b/crates/api-macros/tests/http_adapters.rs index e75d8ab56..729af54f1 100644 --- a/crates/api-macros/tests/http_adapters.rs +++ b/crates/api-macros/tests/http_adapters.rs @@ -131,6 +131,84 @@ impl BinaryApi for BinaryService { } } +#[api(reqwest, axum)] +pub trait ResponseApi { + #[get( + "/conditional", + operation_id = "responses.conditional", + responses = [ + (status = 200, body = Widget, headers = [("etag", String), ("cache-control", String)]), + (status = 304, headers = [("etag", String), ("cache-control", String)]) + ] + )] + async fn conditional(&self, #[query] lookup: Lookup) -> response_api_responses::Conditional; + + #[post( + "/cookie", + operation_id = "responses.cookie", + responses = [ + (status = 200, body = Widget, headers = [("set-cookie", String)]) + ] + )] + async fn cookie(&self) -> response_api_responses::Cookie; +} + +#[derive(Clone)] +pub struct ResponseService; + +impl ResponseApi for ResponseService { + async fn conditional(&self, lookup: Lookup) -> response_api_responses::Conditional { + if lookup.mode == "cached" { + response_api_responses::Conditional::Status304 { + header_etag: "\"widget-v1\"".to_owned(), + header_cache_control: "no-cache".to_owned(), + } + } else { + response_api_responses::Conditional::Status200 { + body: Widget { + id: 42, + name: "conditional".to_owned(), + mode: lookup.mode, + }, + header_etag: "\"widget-v1\"".to_owned(), + header_cache_control: "no-cache".to_owned(), + } + } + } + + async fn cookie(&self) -> response_api_responses::Cookie { + response_api_responses::Cookie::Status200 { + body: Widget { + id: 7, + name: "cookie".to_owned(), + mode: "session".to_owned(), + }, + header_set_cookie: "session=top-secret; HttpOnly; SameSite=Lax".to_owned(), + } + } +} + +#[api(reqwest)] +pub trait ResponseFailureApi { + #[get( + "/missing-header", + responses = [(status = 200, headers = [("x-count", u32)])] + )] + async fn missing_header(&self) -> response_failure_api_responses::MissingHeader; + + #[get( + "/invalid-header", + responses = [(status = 200, headers = [("x-count", u32)])] + )] + async fn invalid_header(&self) -> response_failure_api_responses::InvalidHeader; + + #[get( + "/duplicate-header", + responses = [(status = 200, headers = [("x-count", u32)])] + )] + async fn duplicate_header(&self) -> response_failure_api_responses::DuplicateHeader; +} + #[api(reqwest)] pub trait FailureApi { #[get("/malformed", operation_id = "failure.malformed", status = 200)] @@ -175,6 +253,27 @@ impl api_reqwest::RequestAuthorizer for RecordingAuthorizer { } } +async fn missing_header() -> api_macros::axum::framework::StatusCode { + api_macros::axum::framework::StatusCode::OK +} + +async fn invalid_header() -> api_macros::axum::framework::Response { + api_macros::axum::framework::Response::builder() + .status(api_macros::axum::framework::StatusCode::OK) + .header("x-count", "not-a-number") + .body(api_macros::axum::framework::Body::empty()) + .expect("valid fixture response") +} + +async fn duplicate_header() -> api_macros::axum::framework::Response { + api_macros::axum::framework::Response::builder() + .status(api_macros::axum::framework::StatusCode::OK) + .header("x-count", "1") + .header("x-count", "2") + .body(api_macros::axum::framework::Body::empty()) + .expect("valid fixture response") +} + async fn malformed() -> &'static str { "this is not json" } @@ -321,6 +420,119 @@ async fn generated_client_and_router_follow_the_normalized_contract() { server.abort(); } +#[tokio::test] +async fn declared_response_status_bodies_and_headers_round_trip_without_secret_diagnostics() { + let app = ResponseApiAxum::router(ResponseService).merge( + api_macros::axum::framework::Router::new() + .route( + "/missing-header", + api_macros::axum::framework::get(missing_header), + ) + .route( + "/invalid-header", + api_macros::axum::framework::get(invalid_header), + ) + .route( + "/duplicate-header", + api_macros::axum::framework::get(duplicate_header), + ), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind declared-response 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 declared-response fixture API"); + }); + let base_url = format!("http://{address}/"); + let client = ResponseApiClient::try_new(&base_url).expect("response client"); + + let fresh = client + .conditional(Lookup { + mode: "fresh".to_owned(), + }) + .await + .expect("200 declared response"); + match fresh { + response_api_responses::Conditional::Status200 { + body, + header_etag, + header_cache_control, + } => { + assert_eq!(body.id, 42); + assert_eq!(header_etag, "\"widget-v1\""); + assert_eq!(header_cache_control, "no-cache"); + } + other => panic!("unexpected fresh variant: {other:?}"), + } + + let cached = client + .conditional(Lookup { + mode: "cached".to_owned(), + }) + .await + .expect("304 declared response"); + assert!(matches!( + cached, + response_api_responses::Conditional::Status304 { + ref header_etag, + ref header_cache_control, + } if header_etag == "\"widget-v1\"" && header_cache_control == "no-cache" + )); + + let cookie = client.cookie().await.expect("cookie response"); + let cookie_debug = format!("{cookie:?}"); + assert!(!cookie_debug.contains("top-secret")); + match cookie { + response_api_responses::Cookie::Status200 { + body, + header_set_cookie, + } => { + assert_eq!(body.name, "cookie"); + assert_eq!( + header_set_cookie, + "session=top-secret; HttpOnly; SameSite=Lax" + ); + } + } + + let failures = ResponseFailureApiClient::try_new(&base_url).expect("failure client"); + let missing = failures + .missing_header() + .await + .expect_err("missing header must fail"); + assert!(matches!( + missing, + api_reqwest::ClientError::Failure(api_reqwest::ClientFailure::MissingResponseHeader { + name: "x-count" + }) + )); + let invalid = failures + .invalid_header() + .await + .expect_err("invalid header must fail"); + assert!(matches!( + invalid, + api_reqwest::ClientError::Failure(api_reqwest::ClientFailure::InvalidResponseHeader { + name: "x-count" + }) + )); + let duplicate = failures + .duplicate_header() + .await + .expect_err("duplicate header must fail"); + assert!(matches!( + duplicate, + api_reqwest::ClientError::Failure(api_reqwest::ClientFailure::DuplicateResponseHeader { + name: "x-count" + }) + )); + + server.abort(); +} + #[tokio::test] async fn binary_adapter_preserves_exact_bytes_content_type_and_route_limit() { let app = binary_api_axum::upload(Arc::new(BinaryService)) diff --git a/crates/api-macros/tests/openapi.rs b/crates/api-macros/tests/openapi.rs index d1c96c544..ea75ee3a3 100644 --- a/crates/api-macros/tests/openapi.rs +++ b/crates/api-macros/tests/openapi.rs @@ -103,6 +103,16 @@ pub trait FixtureApi { #[put("/uploads", operation_id = "uploads.put", status = 200)] async fn upload(&self, #[binary] body: BinaryBody) -> Widget; + + #[get( + "/conditional", + operation_id = "conditional.get", + responses = [ + (status = 200, body = Widget, headers = [("etag", String), ("cache-control", String)]), + (status = 304, headers = [("etag", String), ("cache-control", String)]) + ] + )] + async fn conditional(&self) -> fixture_api_responses::Conditional; } fn document() -> api_macros::openapi::OpenApiDocument { @@ -303,6 +313,26 @@ fn operations_and_components_preserve_the_wire_contract() { "binary payloads are not JSON Schema components" ); + let conditional = &value["paths"]["/conditional"]["get"]; + assert_eq!( + conditional["responses"]["200"]["content"]["application/json"]["schema"], + json!({ "$ref": "#/components/schemas/Widget" }) + ); + assert_eq!( + conditional["responses"]["200"]["headers"]["etag"]["schema"], + json!({ "$ref": "#/components/schemas/string" }) + ); + assert_eq!( + conditional["responses"]["304"], + json!({ + "description": "Alternate successful response", + "headers": { + "cache-control": { "schema": { "$ref": "#/components/schemas/string" } }, + "etag": { "schema": { "$ref": "#/components/schemas/string" } } + } + }) + ); + let delete = &value["paths"]["/widgets/{widget_id}"]["delete"]; assert_eq!(delete["operationId"], "widgets.delete"); assert!(delete.get("security").is_none()); diff --git a/crates/api-macros/tests/ui/duplicate_response_header.rs b/crates/api-macros/tests/ui/duplicate_response_header.rs new file mode 100644 index 000000000..f900ba1ba --- /dev/null +++ b/crates/api-macros/tests/ui/duplicate_response_header.rs @@ -0,0 +1,14 @@ +use api_macros::api; + +pub struct Body; + +#[api] +pub trait DuplicateResponseHeaderApi { + #[get( + "/duplicate", + responses = [(status = 200, body = Body, headers = [("etag", String), ("ETag", String)])] + )] + async fn duplicate(&self) -> duplicate_response_header_api_responses::Duplicate; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/duplicate_response_header.stderr b/crates/api-macros/tests/ui/duplicate_response_header.stderr new file mode 100644 index 000000000..f899088d7 --- /dev/null +++ b/crates/api-macros/tests/ui/duplicate_response_header.stderr @@ -0,0 +1,5 @@ +error: duplicate response header name within one response + --> tests/ui/duplicate_response_header.rs:9:80 + | +9 | responses = [(status = 200, body = Body, headers = [("etag", String), ("ETag", String)])] + | ^^^^^^ diff --git a/crates/api-macros/tests/ui/invalid_response_header.rs b/crates/api-macros/tests/ui/invalid_response_header.rs new file mode 100644 index 000000000..5901fc26c --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_response_header.rs @@ -0,0 +1,14 @@ +use api_macros::api; + +pub struct Body; + +#[api] +pub trait InvalidResponseHeaderApi { + #[get( + "/invalid", + responses = [(status = 200, body = Body, headers = [("bad header", String)])] + )] + async fn invalid(&self) -> invalid_response_header_api_responses::Invalid; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/invalid_response_header.stderr b/crates/api-macros/tests/ui/invalid_response_header.stderr new file mode 100644 index 000000000..90da20c9a --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_response_header.stderr @@ -0,0 +1,5 @@ +error: response header name must be a valid HTTP field name + --> tests/ui/invalid_response_header.rs:9:62 + | +9 | responses = [(status = 200, body = Body, headers = [("bad header", String)])] + | ^^^^^^^^^^^^ diff --git a/crates/api-macros/tests/ui/not_modified_body.rs b/crates/api-macros/tests/ui/not_modified_body.rs new file mode 100644 index 000000000..4e3870d55 --- /dev/null +++ b/crates/api-macros/tests/ui/not_modified_body.rs @@ -0,0 +1,14 @@ +use api_macros::api; + +pub struct Body; + +#[api] +pub trait NotModifiedBodyApi { + #[get( + "/not-modified", + responses = [(status = 304, body = Body)] + )] + async fn not_modified(&self) -> not_modified_body_api_responses::NotModified; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/not_modified_body.stderr b/crates/api-macros/tests/ui/not_modified_body.stderr new file mode 100644 index 000000000..c5e89f291 --- /dev/null +++ b/crates/api-macros/tests/ui/not_modified_body.stderr @@ -0,0 +1,5 @@ +error: status 304 cannot have a response body + --> tests/ui/not_modified_body.rs:9:32 + | +9 | responses = [(status = 304, body = Body)] + | ^^^ diff --git a/crates/api-macros/tests/ui/undeclared_response_status.rs b/crates/api-macros/tests/ui/undeclared_response_status.rs new file mode 100644 index 000000000..6fcf60e34 --- /dev/null +++ b/crates/api-macros/tests/ui/undeclared_response_status.rs @@ -0,0 +1,18 @@ +use api_macros::api; + +pub struct Body; + +#[api] +pub trait UndeclaredStatusApi { + #[get( + "/status", + responses = [(status = 200, body = Body)] + )] + async fn status(&self) -> undeclared_status_api_responses::Status; +} + +fn invalid() -> undeclared_status_api_responses::Status { + undeclared_status_api_responses::Status::Status201 { body: Body } +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/undeclared_response_status.stderr b/crates/api-macros/tests/ui/undeclared_response_status.stderr new file mode 100644 index 000000000..b9a5e77e3 --- /dev/null +++ b/crates/api-macros/tests/ui/undeclared_response_status.stderr @@ -0,0 +1,14 @@ +error[E0599]: no variant named `Status201` found for enum `undeclared_status_api_responses::Status` + --> tests/ui/undeclared_response_status.rs:15:46 + | + 5 | #[api] + | ------ variant `Status201` not found here +... +15 | undeclared_status_api_responses::Status::Status201 { body: Body } + | ^^^^^^^^^ + | +help: there is a variant with a similar name + | +15 - undeclared_status_api_responses::Status::Status201 { body: Body } +15 + undeclared_status_api_responses::Status::Status200 { body: Body } + | diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 6fff383f6..ede36288f 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -154,8 +154,14 @@ impl_openapi_schema!( HealthResponse, PasskeyLoginOptionsRequest, PasskeyLoginOptionsResponse, + PasskeyLoginCompleteRequest, PasskeyRegistrationOptionsRequest, PasskeyRegistrationOptionsResponse, + PasskeyRegistrationCompleteRequest, + LogoutResponse, + SetCookieHeader, + WorkspaceRuntimeConfigQuery, + WorkspaceRuntimeConfigResponse, WhoamiResponse, WorkspaceCreateRequest, WorkspaceCreateResponse, @@ -412,6 +418,19 @@ pub trait ServerApi { #[body] request: PasskeyRegistrationOptionsRequest, ) -> Result; + #[post( + "/api/auth/passkeys/registration/complete", + responses = [ + (status = 200, body = AuthUserResponse, headers = [("set-cookie", SetCookieHeader)]) + ], + error_status = 400, + additional_error_statuses = [500] + )] + async fn auth_passkey_registration_complete( + &self, + #[body] request: PasskeyRegistrationCompleteRequest, + ) -> Result; + #[post( "/api/auth/passkeys/login/options", status = 200, @@ -424,6 +443,19 @@ pub trait ServerApi { #[body] request: PasskeyLoginOptionsRequest, ) -> Result; + #[post( + "/api/auth/passkeys/login/complete", + responses = [ + (status = 200, body = AuthUserResponse, headers = [("set-cookie", SetCookieHeader)]) + ], + error_status = 400, + additional_error_statuses = [500] + )] + async fn auth_passkey_login_complete( + &self, + #[body] request: PasskeyLoginCompleteRequest, + ) -> Result; + #[post( "/api/auth/device-login/start", status = 200, @@ -466,6 +498,19 @@ pub trait ServerApi { #[extension] context: ServerRequestContext, ) -> Result; + #[post( + "/api/auth/logout", + responses = [ + (status = 200, body = LogoutResponse, headers = [("set-cookie", SetCookieHeader)]) + ], + error_status = 400, + additional_error_statuses = [500] + )] + async fn auth_logout( + &self, + #[header("cookie")] cookie: Option, + ) -> Result; + #[get( "/api/workspaces", status = 200, @@ -564,6 +609,23 @@ pub trait ServerApi { #[path] workspace_id: String, ) -> Result; + #[get( + "/api/w/{workspace_id}/runtime-config", + responses = [ + (status = 200, body = WorkspaceRuntimeConfigResponse, headers = [("etag", String), ("cache-control", String)]), + (status = 304, headers = [("etag", String), ("cache-control", String)]) + ], + error_status = 400, + additional_error_statuses = [403, 404, 500] + )] + async fn workspace_runtime_config( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[query] query: WorkspaceRuntimeConfigQuery, + #[header("if-none-match")] if_none_match: Option, + ) -> Result; + #[get( "/api/w/{workspace_id}/settings", status = 200, @@ -3010,12 +3072,13 @@ pub struct PasskeyRegistrationOptionsResponse { pub public_key: CreationChallengeResponse, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct PasskeyRegistrationCompleteRequest { pub challenge_id: String, #[cfg_attr(feature = "typescript", ts(type = "unknown"))] + #[schemars(with = "serde_json::Value")] pub credential: RegisterPublicKeyCredential, } @@ -3041,15 +3104,64 @@ pub struct PasskeyLoginOptionsResponse { pub public_key: RequestChallengeResponse, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct PasskeyLoginCompleteRequest { pub challenge_id: String, #[cfg_attr(feature = "typescript", ts(type = "unknown"))] + #[schemars(with = "serde_json::Value")] pub credential: PublicKeyCredential, } +/// Sensitive `Set-Cookie` response header value. +/// +/// Formatting is available only for transport encoding; `Debug` always redacts the value. +#[derive(Clone, Eq, JsonSchema, PartialEq)] +#[schemars(transparent)] +pub struct SetCookieHeader(String); + +impl SetCookieHeader { + pub fn new(value: String) -> Self { + Self(value) + } + + pub fn expose(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Debug for SetCookieHeader { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("SetCookieHeader()") + } +} + +impl std::fmt::Display for SetCookieHeader { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.0) + } +} + +impl std::str::FromStr for SetCookieHeader { + type Err = std::convert::Infallible; + + fn from_str(value: &str) -> Result { + Ok(Self(value.to_owned())) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceRuntimeConfigQuery { + pub profile: String, +} + +/// Transparent JSON body for the Runtime-owned config bundle wire format. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] +#[serde(transparent)] +pub struct WorkspaceRuntimeConfigResponse(pub serde_json::Value); + #[derive(Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] @@ -3203,14 +3315,14 @@ impl std::fmt::Debug for DeviceLoginPollResponse { } } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] pub enum LogoutStatus { LoggedOut, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct LogoutResponse { @@ -10587,9 +10699,12 @@ mod openapi_artifact_tests { "auth_config", "auth_bootstrap_user", "auth_passkey_registration_options", + "auth_passkey_registration_complete", "auth_passkey_login_options", + "auth_passkey_login_complete", "auth_device_login_start", "auth_device_login_poll", + "auth_logout", "auth_whoami", ]; // These operations are authenticated by Runtime request-source proof, Worker source @@ -10620,6 +10735,7 @@ mod openapi_artifact_tests { // Session observation retains its existing explicit non-OpenAPI boundary independently of // the Runtime/Worker-source signed operation inventory above. const OTHER_OPENAPI_EXCLUDED: &[&str] = &["worker_session"]; + const DOCUMENTED_SIGNED_INTERNAL: &[&str] = &["workspace_runtime_config"]; const BROWSER_ONLY: &[&str] = &["merge_request_selector_repair"]; let document = canonical_openapi_document().expect("canonical OpenAPI contract must build"); @@ -10667,7 +10783,9 @@ mod openapi_artifact_tests { let documented_operation = documented .get(operation_id) .unwrap_or_else(|| panic!("missing public OpenAPI operation {operation_id}")); - let expected = if PUBLIC_UNAUTHENTICATED.contains(&operation_id) { + let expected = if PUBLIC_UNAUTHENTICATED.contains(&operation_id) + || DOCUMENTED_SIGNED_INTERNAL.contains(&operation_id) + { &[][..] } else if BROWSER_ONLY.contains(&operation_id) { &["browserSession"][..] @@ -10705,7 +10823,7 @@ mod openapi_artifact_tests { } #[test] - fn auth_and_workspace_catalog_contract_is_strict_and_excludes_cookie_routes() { + fn auth_workspace_catalog_and_typed_header_contract_is_strict() { let document = canonical_openapi_document().expect("canonical OpenAPI contract must build"); let value: serde_json::Value = serde_json::from_str(&document.to_json().expect("document must serialize")) @@ -10716,7 +10834,10 @@ mod openapi_artifact_tests { ("/api/auth/config", "get"), ("/api/auth/bootstrap-user", "post"), ("/api/auth/passkeys/registration/options", "post"), + ("/api/auth/passkeys/registration/complete", "post"), ("/api/auth/passkeys/login/options", "post"), + ("/api/auth/passkeys/login/complete", "post"), + ("/api/auth/logout", "post"), ("/api/auth/device-login/start", "post"), ("/api/auth/device-login/approve", "post"), ("/api/auth/device-login/poll", "post"), @@ -10755,10 +10876,30 @@ mod openapi_artifact_tests { "/api/auth/passkeys/login/complete", "/api/auth/logout", ] { - assert!( - value["paths"].get(path).is_none(), - "cookie response route {path} must remain outside ServerApi" + let response = &value["paths"][path]["post"]["responses"]["200"]; + assert_eq!( + response["headers"]["set-cookie"]["schema"]["$ref"], + "#/components/schemas/SetCookieHeader", + "cookie response route {path} must declare Set-Cookie" ); + assert_eq!( + response["content"]["application/json"]["schema"]["$ref"], + if path == "/api/auth/logout" { + "#/components/schemas/LogoutResponse" + } else { + "#/components/schemas/AuthUserResponse" + } + ); + } + let runtime_config = &value["paths"]["/api/w/{workspace_id}/runtime-config"]["get"]; + assert_eq!( + runtime_config["responses"]["200"]["content"]["application/json"]["schema"]["$ref"], + "#/components/schemas/WorkspaceRuntimeConfigResponse" + ); + assert!(runtime_config["responses"]["304"].get("content").is_none()); + for status in ["200", "304"] { + assert!(runtime_config["responses"][status]["headers"]["etag"].is_object()); + assert!(runtime_config["responses"][status]["headers"]["cache-control"].is_object()); } for schema in [ "RepositoryApiError", diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 7eff19849..b8a2af3a3 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -7,13 +7,15 @@ use std::sync::{Arc, Mutex, RwLock, Weak}; use async_trait::async_trait; use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}; use axum::extract::{DefaultBodyLimit, Extension, Path as AxumPath, Query, Request, State}; -use axum::http::header::{ - CACHE_CONTROL, CONTENT_TYPE, ETAG, IF_NONE_MATCH, LOCATION, ORIGIN, SET_COOKIE, -}; +#[cfg(test)] +use axum::http::header::{CACHE_CONTROL, ETAG, IF_NONE_MATCH}; +use axum::http::header::{CONTENT_TYPE, LOCATION, ORIGIN}; use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri}; use axum::middleware::{self, Next}; use axum::response::{IntoResponse, Response}; -use axum::routing::{get, post}; +use axum::routing::get; +#[cfg(test)] +use axum::routing::post; use axum::{Json, Router}; use chrono::{Duration, SecondsFormat, Utc}; use config_source::ConfigTreeSnapshot; @@ -2550,10 +2552,6 @@ pub async fn build_workspace_server_router( template: ServerConfig, store: Arc, ) -> Result { - let auth = build_server_auth_router(ServerAuthApi { - config: template.clone(), - store: store.clone(), - }); let api = WorkspaceServerApi::new(template, store); api.preload().await?; api.recover_workspace_deletions().await?; @@ -2568,12 +2566,10 @@ pub async fn build_workspace_server_router( .merge(generated_workspace_catalog_contract_router( contract_service, )); - Ok(auth - .merge(catalog) - .layer(axum::middleware::from_fn_with_state( - api, - enforce_server_cookie_mutation_origin, - ))) + Ok(catalog.layer(axum::middleware::from_fn_with_state( + api, + enforce_server_cookie_mutation_origin, + ))) } #[cfg(test)] @@ -3636,20 +3632,6 @@ fn configured_repository_from_record(record: RepositoryRecord) -> Result Router { - Router::new() - .route( - "/api/auth/passkeys/registration/complete", - post(post_passkey_registration_complete), - ) - .route( - "/api/auth/passkeys/login/complete", - post(post_passkey_login_complete), - ) - .route("/api/auth/logout", post(post_auth_logout)) - .with_state(api) -} - fn server_request_context( actor: Option, worker_source: Option, @@ -3771,6 +3753,11 @@ fn generated_auth_contract_router(service: ServerApiContractService) -> Router { .merge(server_api::server_api_axum::auth_bootstrap_user( service.clone(), )) + .merge(server_api::server_api_axum::auth_passkey_registration_complete(service.clone())) + .merge(server_api::server_api_axum::auth_passkey_login_complete( + service.clone(), + )) + .merge(server_api::server_api_axum::auth_logout(service.clone())) .merge(server_api::server_api_axum::auth_device_login_start( service.clone(), )) @@ -3812,6 +3799,9 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou .merge(server_api::server_api_axum::workspace_scoped( service.clone(), )) + .merge(server_api::server_api_axum::workspace_runtime_config( + service.clone(), + )) .merge(server_api::server_api_axum::workspace_metadata_settings( service.clone(), )) @@ -4592,6 +4582,23 @@ impl server_api::ServerApi for ServerApiContractService { .map_err(ApiError::into_repository_api_error) } + async fn auth_passkey_registration_complete( + &self, + request: PasskeyRegistrationCompleteRequest, + ) -> std::result::Result< + server_api::server_api_responses::AuthPasskeyRegistrationComplete, + server_api::RepositoryApiError, + > { + let (body, header_set_cookie) = complete_passkey_registration(&self.auth_api(), request) + .map_err(ApiError::into_repository_api_error)?; + Ok( + server_api::server_api_responses::AuthPasskeyRegistrationComplete::Status200 { + body, + header_set_cookie, + }, + ) + } + async fn auth_passkey_login_options( &self, context: server_api::ServerRequestContext, @@ -4606,6 +4613,23 @@ impl server_api::ServerApi for ServerApiContractService { .map_err(ApiError::into_repository_api_error) } + async fn auth_passkey_login_complete( + &self, + request: PasskeyLoginCompleteRequest, + ) -> std::result::Result< + server_api::server_api_responses::AuthPasskeyLoginComplete, + server_api::RepositoryApiError, + > { + let (body, header_set_cookie) = complete_passkey_login(&self.auth_api(), request) + .map_err(ApiError::into_repository_api_error)?; + Ok( + server_api::server_api_responses::AuthPasskeyLoginComplete::Status200 { + body, + header_set_cookie, + }, + ) + } + async fn auth_device_login_start( &self, request: DeviceLoginStartRequest, @@ -4652,6 +4676,21 @@ impl server_api::ServerApi for ServerApiContractService { }) } + async fn auth_logout( + &self, + cookie: Option, + ) -> std::result::Result< + server_api::server_api_responses::AuthLogout, + server_api::RepositoryApiError, + > { + let (body, header_set_cookie) = logout_browser_session(&self.auth_api(), cookie) + .map_err(ApiError::into_repository_api_error)?; + Ok(server_api::server_api_responses::AuthLogout::Status200 { + body, + header_set_cookie, + }) + } + async fn workspace_catalog_list( &self, context: server_api::ServerRequestContext, @@ -4830,6 +4869,25 @@ impl server_api::ServerApi for ServerApiContractService { .map_err(ApiError::into_repository_api_error) } + async fn workspace_runtime_config( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + query: server_api::WorkspaceRuntimeConfigQuery, + if_none_match: Option, + ) -> std::result::Result< + server_api::server_api_responses::WorkspaceRuntimeConfig, + server_api::RepositoryApiError, + > { + workspace_runtime_config_result( + self.workspace_api()?, + context, + &workspace_id, + query, + if_none_match, + ) + } + async fn workspace_metadata_settings( &self, workspace_id: String, @@ -8252,8 +8310,7 @@ impl server_api::ServerApi for ServerApiContractService { fn build_inner_router(api: WorkspaceApi) -> Router { let contract_service = ServerApiContractService::Workspace(api.clone()); - let auth = build_server_auth_router(ServerAuthApi::from(&api)) - .merge(generated_auth_contract_router(contract_service.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. @@ -8261,10 +8318,6 @@ fn build_inner_router(api: WorkspaceApi) -> Router { "/api/w/{workspace_id}/external-workdir-grants/{grant_id}/provider", get(scoped_external_workdir_provider_ws), ) - .route( - "/api/w/{workspace_id}/runtime-config", - get(get_latest_workspace_runtime_config), - ) .route( "/api/w/{workspace_id}/protocol/ws", get(scoped_workspace_protocol_ws), @@ -14720,99 +14773,104 @@ async fn scoped_list_hosts( list_hosts(State(api)).await } -#[derive(Debug, Deserialize)] -struct LatestWorkspaceRuntimeConfigQuery { - profile: String, -} - -async fn get_latest_workspace_runtime_config( - State(api): State, - AxumPath(path): AxumPath, - Query(query): Query, - headers: HeaderMap, - source: Option>, -) -> Response { - let Some(Extension(_source)) = source else { - return ( - StatusCode::FORBIDDEN, - Json(serde_json::json!({ "error": "runtime_config_source_required" })), - ) - .into_response(); - }; - if let Err(error) = validate_workspace_scope(&api, &path.workspace_id) { - return error.into_response(); +fn workspace_runtime_config_result( + api: &WorkspaceApi, + context: server_api::ServerRequestContext, + workspace_id: &str, + query: server_api::WorkspaceRuntimeConfigQuery, + if_none_match: Option, +) -> std::result::Result< + server_api::server_api_responses::WorkspaceRuntimeConfig, + server_api::RepositoryApiError, +> { + if context.runtime_source.is_none() { + return Err(server_api::RepositoryApiError::new( + StatusCode::FORBIDDEN.as_u16(), + "runtime_config_source_required", + "runtime config requires an authenticated Runtime source", + Vec::new(), + )); } - let config_state = match api.config_store.load_workspace_config(&path.workspace_id) { - Ok(Some(config)) => config, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ "error": "workspace_config_not_found" })), + validate_workspace_scope(api, workspace_id).map_err(ApiError::into_repository_api_error)?; + let config_state = api + .config_store + .load_workspace_config(workspace_id) + .map_err(|error| ApiError::from(error).into_repository_api_error())? + .ok_or_else(|| { + server_api::RepositoryApiError::new( + StatusCode::NOT_FOUND.as_u16(), + "workspace_config_not_found", + "Workspace Config was not found", + Vec::new(), ) - .into_response(); - } - Err(error) => return ApiError::from(error).into_response(), - }; - let profile_projection = match crate::profile_settings::project_profiles_from_workspace_config( - &path.workspace_id, + })?; + let profile_projection = crate::profile_settings::project_profiles_from_workspace_config( + workspace_id, &config_state, - ) { - Ok(projection) => projection, - Err(error) => return ApiError::from(error).into_response(), - }; + ) + .map_err(|error| ApiError::from(error).into_repository_api_error())?; if crate::profile_settings::selector_for_workspace_candidate( &profile_projection, &query.profile, ) .is_none() { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ "error": "runtime_config_profile_not_found" })), - ) - .into_response(); - }; - let prompt_catalog = match api + return Err(server_api::RepositoryApiError::new( + StatusCode::NOT_FOUND.as_u16(), + "runtime_config_profile_not_found", + "Runtime Config profile was not found", + Vec::new(), + )); + } + let prompt_catalog = api .prompt_projection_cache - .resolve(&path.workspace_id, &config_state) - { - Ok(catalog) => catalog, - Err(error) => return ApiError::from(error).into_response(), - }; - let Some(bundle) = - (match crate::profile_settings::build_virtual_profile_config_bundle_with_prompt_projection( + .resolve(workspace_id, &config_state) + .map_err(|error| ApiError::from(error).into_repository_api_error())?; + let bundle = + crate::profile_settings::build_virtual_profile_config_bundle_with_prompt_projection( &profile_projection, &config_state, - &path.workspace_id, + workspace_id, &api.config.workspace_created_at, &query.profile, prompt_catalog.as_ref(), - ) { - Ok(bundle) => bundle, - Err(error) => return ApiError::from(error).into_response(), - }) - else { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ "error": "runtime_config_profile_not_found" })), ) - .into_response(); - }; + .map_err(|error| ApiError::from(error).into_repository_api_error())? + .ok_or_else(|| { + server_api::RepositoryApiError::new( + StatusCode::NOT_FOUND.as_u16(), + "runtime_config_profile_not_found", + "Runtime Config profile was not found", + Vec::new(), + ) + })?; let etag = worker_runtime::config_bundle::workspace_config_etag(&bundle.metadata.digest); - let mut response_headers = HeaderMap::new(); - response_headers.insert(ETAG, etag.parse().expect("Workspace Config ETag is valid")); - response_headers.insert( - CACHE_CONTROL, - "no-cache".parse().expect("valid cache policy"), - ); - if headers - .get(IF_NONE_MATCH) - .and_then(|value| value.to_str().ok()) - .is_some_and(|value| value.split(',').any(|candidate| candidate.trim() == etag)) + let header_cache_control = "no-cache".to_owned(); + if if_none_match.is_some_and(|value| value.split(',').any(|candidate| candidate.trim() == etag)) { - return (StatusCode::NOT_MODIFIED, response_headers).into_response(); + return Ok( + server_api::server_api_responses::WorkspaceRuntimeConfig::Status304 { + header_etag: etag, + header_cache_control, + }, + ); } - (StatusCode::OK, response_headers, Json(bundle)).into_response() + let body = serde_json::to_value(bundle) + .map(server_api::WorkspaceRuntimeConfigResponse) + .map_err(|error| { + ApiError::from(invalid_contract_value( + "Workspace Runtime Config response", + error, + )) + .into_repository_api_error() + })?; + Ok( + server_api::server_api_responses::WorkspaceRuntimeConfig::Status200 { + body, + header_etag: etag, + header_cache_control, + }, + ) } async fn scoped_list_runtimes( @@ -18959,10 +19017,10 @@ async fn post_passkey_registration_options( })) } -async fn post_passkey_registration_complete( - State(api): State, - Json(request): Json, -) -> ApiResult { +fn complete_passkey_registration( + api: &ServerAuthApi, + request: PasskeyRegistrationCompleteRequest, +) -> ApiResult<(AuthUserResponse, server_api::SetCookieHeader)> { let challenge = api .store .consume_auth_challenge_by_id( @@ -19025,7 +19083,7 @@ async fn post_passkey_registration_complete( created_at: crate::auth::now_rfc3339(), last_used_at: None, })?; - issue_browser_session_response(&api, user) + issue_browser_session(&api, user) } async fn post_passkey_login_options( @@ -19079,10 +19137,10 @@ async fn post_passkey_login_options( })) } -async fn post_passkey_login_complete( - State(api): State, - Json(request): Json, -) -> ApiResult { +fn complete_passkey_login( + api: &ServerAuthApi, + request: PasskeyLoginCompleteRequest, +) -> ApiResult<(AuthUserResponse, server_api::SetCookieHeader)> { let challenge = api .store .consume_auth_challenge_by_id( @@ -19160,10 +19218,13 @@ async fn post_passkey_login_complete( created_at: stored.created_at, last_used_at: Some(crate::auth::now_rfc3339()), })?; - issue_browser_session_response(&api, user) + issue_browser_session(&api, user) } -fn issue_browser_session_response(api: &ServerAuthApi, user: UserRecord) -> ApiResult { +fn issue_browser_session( + api: &ServerAuthApi, + user: UserRecord, +) -> ApiResult<(AuthUserResponse, server_api::SetCookieHeader)> { let session_token = mint_secret("yoi_sess"); api.store.create_browser_session(&BrowserSessionRecord { session_id: new_id("session"), @@ -19174,29 +19235,17 @@ fn issue_browser_session_response(api: &ServerAuthApi, user: UserRecord) -> ApiR revoked_at: None, })?; let auth = auth_public_config(&api.config); - let mut headers = HeaderMap::new(); - headers.insert( - SET_COOKIE, - session_set_cookie( - session_cookie_policy(&auth), - &session_token, - 14 * 24 * 60 * 60, - ) - .parse() - .map_err(|error| { - auth_error( - "invalid_session_cookie", - &format!("failed to build session cookie: {error}"), - ) - })?, + let cookie = session_set_cookie( + session_cookie_policy(&auth), + &session_token, + 14 * 24 * 60 * 60, ); Ok(( - headers, - Json(AuthUserResponse { + AuthUserResponse { user: user_response(user), - }), - ) - .into_response()) + }, + server_api::SetCookieHeader::new(cookie), + )) } async fn post_device_login_start( @@ -19347,35 +19396,32 @@ async fn post_device_login_poll( })) } -async fn post_auth_logout( - State(api): State, - headers: HeaderMap, -) -> ApiResult { +fn logout_browser_session( + api: &ServerAuthApi, + cookie: Option, +) -> ApiResult<(LogoutResponse, server_api::SetCookieHeader)> { let auth = auth_public_config(&api.config); + let mut headers = HeaderMap::new(); + if let Some(cookie) = cookie { + let value = cookie.parse::().map_err(|_| { + auth_error( + "invalid_session_cookie", + "request cookie header could not be represented", + ) + })?; + headers.insert(axum::http::header::COOKIE, value); + } if let Some(session_token) = parse_cookie(&headers, &auth.cookie_name) { let _ = api .store .revoke_browser_session(&token_hash(&session_token), &crate::auth::now_rfc3339())?; } - let mut response_headers = HeaderMap::new(); - response_headers.insert( - SET_COOKIE, - session_set_cookie(session_cookie_policy(&auth), "", 0) - .parse() - .map_err(|error| { - auth_error( - "invalid_session_cookie", - &format!("failed to build logout cookie: {error}"), - ) - })?, - ); Ok(( - response_headers, - Json(LogoutResponse { + LogoutResponse { status: LogoutStatus::LoggedOut, - }), - ) - .into_response()) + }, + server_api::SetCookieHeader::new(session_set_cookie(session_cookie_policy(&auth), "", 0)), + )) } fn request_origin(headers: &HeaderMap) -> Option { @@ -31586,15 +31632,30 @@ mod tests { .unwrap(); let auth_api = ServerAuthApi::from(&api); - let login = issue_browser_session_response(&auth_api, user).unwrap(); - let login_cookie = login.headers().get(SET_COOKIE).unwrap().to_str().unwrap(); + let (_, login) = issue_browser_session(&auth_api, user).unwrap(); + let login_cookie = login.expose(); assert_eq!(login_cookie.contains("; Secure"), secure, "{scheme}"); assert!(login_cookie.contains("; HttpOnly; SameSite=Lax")); - let logout = post_auth_logout(State(auth_api), HeaderMap::new()) + let app = generated_auth_contract_router(ServerApiContractService::Workspace(api)); + let response = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/auth/logout") + .header(axum::http::header::COOKIE, login_cookie) + .body(Body::empty()) + .unwrap(), + ) .await .unwrap(); - let logout_cookie = logout.headers().get(SET_COOKIE).unwrap().to_str().unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let logout_cookie = response + .headers() + .get(axum::http::header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap(); assert_eq!(logout_cookie.contains("; Secure"), secure, "{scheme}"); assert!(logout_cookie.contains("Max-Age=0")); assert!(logout_cookie.contains("; HttpOnly; SameSite=Lax")); diff --git a/openapi/server-api.json b/openapi/server-api.json index a30ce4d0c..ade0cd4ac 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -2571,6 +2571,24 @@ ], "type": "object" }, + "LogoutResponse": { + "additionalProperties": false, + "properties": { + "status": { + "$ref": "#/components/schemas/LogoutStatus" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "LogoutStatus": { + "enum": [ + "logged_out" + ], + "type": "string" + }, "MarkdownText": { "type": "string" }, @@ -4339,6 +4357,12 @@ ], "type": "object" }, + "Nullable_string": { + "type": [ + "string", + "null" + ] + }, "ObjectiveCreateRequest": { "additionalProperties": false, "properties": { @@ -4990,6 +5014,20 @@ ], "type": "object" }, + "PasskeyLoginCompleteRequest": { + "additionalProperties": false, + "properties": { + "challenge_id": { + "type": "string" + }, + "credential": true + }, + "required": [ + "challenge_id", + "credential" + ], + "type": "object" + }, "PasskeyLoginOptionsRequest": { "additionalProperties": false, "properties": { @@ -5022,6 +5060,20 @@ ], "type": "object" }, + "PasskeyRegistrationCompleteRequest": { + "additionalProperties": false, + "properties": { + "challenge_id": { + "type": "string" + }, + "credential": true + }, + "required": [ + "challenge_id", + "credential" + ], + "type": "object" + }, "PasskeyRegistrationOptionsRequest": { "additionalProperties": false, "properties": { @@ -8075,6 +8127,10 @@ "minimum": 0, "type": "integer" }, + "SetCookieHeader": { + "description": "Sensitive `Set-Cookie` response header value.\n\nFormatting is available only for transport encoding; `Debug` always redacts the value.", + "type": "string" + }, "SetTicketRoleAssignmentRequest": { "additionalProperties": false, "properties": { @@ -13072,6 +13128,21 @@ ], "type": "object" }, + "WorkspaceRuntimeConfigQuery": { + "additionalProperties": false, + "properties": { + "profile": { + "type": "string" + } + }, + "required": [ + "profile" + ], + "type": "object" + }, + "WorkspaceRuntimeConfigResponse": { + "description": "Transparent JSON body for the Runtime-owned config bundle wire format." + }, "WorkspaceRuntimeDetail": { "additionalProperties": false, "properties": { @@ -13393,7 +13464,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:5cce28b241bb6243d535029b5091d4a546fd90eee9a00fbf2702b450fab67a2d" + "x-yoi-source-digest": "sha256:c76b20e52333efb49a28f9db63d711561bfdc2871e98fd3cbf00d364f2992c21" }, "openapi": "3.1.0", "paths": { @@ -13670,6 +13741,116 @@ } } }, + "/api/auth/logout": { + "post": { + "operationId": "auth_logout", + "parameters": [ + { + "explode": false, + "in": "header", + "name": "cookie", + "required": false, + "schema": { + "$ref": "#/components/schemas/Nullable_string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LogoutResponse" + } + } + }, + "description": "Successful response", + "headers": { + "set-cookie": { + "schema": { + "$ref": "#/components/schemas/SetCookieHeader" + } + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + } + } + }, + "/api/auth/passkeys/login/complete": { + "post": { + "operationId": "auth_passkey_login_complete", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasskeyLoginCompleteRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthUserResponse" + } + } + }, + "description": "Successful response", + "headers": { + "set-cookie": { + "schema": { + "$ref": "#/components/schemas/SetCookieHeader" + } + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + } + } + }, "/api/auth/passkeys/login/options": { "post": { "operationId": "auth_passkey_login_options", @@ -13727,6 +13908,60 @@ } } }, + "/api/auth/passkeys/registration/complete": { + "post": { + "operationId": "auth_passkey_registration_complete", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasskeyRegistrationCompleteRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthUserResponse" + } + } + }, + "description": "Successful response", + "headers": { + "set-cookie": { + "schema": { + "$ref": "#/components/schemas/SetCookieHeader" + } + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + } + } + }, "/api/auth/passkeys/registration/options": { "post": { "operationId": "auth_passkey_registration_options", @@ -20448,6 +20683,122 @@ ] } }, + "/api/w/{workspace_id}/runtime-config": { + "get": { + "operationId": "workspace_runtime_config", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/WorkspaceRuntimeConfigQuery" + }, + "style": "form" + }, + { + "explode": false, + "in": "header", + "name": "if-none-match", + "required": false, + "schema": { + "$ref": "#/components/schemas/Nullable_string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceRuntimeConfigResponse" + } + } + }, + "description": "Successful response", + "headers": { + "cache-control": { + "schema": { + "$ref": "#/components/schemas/string" + } + }, + "etag": { + "schema": { + "$ref": "#/components/schemas/string" + } + } + } + }, + "304": { + "description": "Alternate successful response", + "headers": { + "cache-control": { + "schema": { + "$ref": "#/components/schemas/string" + } + }, + "etag": { + "schema": { + "$ref": "#/components/schemas/string" + } + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + } + } + }, "/api/w/{workspace_id}/runtimes": { "get": { "operationId": "runtime_list", diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index 20360376f..b9066d83f 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -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:5cce28b241bb6243d535029b5091d4a546fd90eee9a00fbf2702b450fab67a2d) +// Input contract: 0.1.0 (sha256:c76b20e52333efb49a28f9db63d711561bfdc2871e98fd3cbf00d364f2992c21) // 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 diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index 79244eeba..c02f0822b 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -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:5cce28b241bb6243d535029b5091d4a546fd90eee9a00fbf2702b450fab67a2d) +// Input contract: 0.1.0 (sha256:c76b20e52333efb49a28f9db63d711561bfdc2871e98fd3cbf00d364f2992c21) // 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 diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index 63e9f27d1..810391909 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -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:5cce28b241bb6243d535029b5091d4a546fd90eee9a00fbf2702b450fab67a2d) +// Input contract: 0.1.0 (sha256:c76b20e52333efb49a28f9db63d711561bfdc2871e98fd3cbf00d364f2992c21) // 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 diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index 380ffe066..f253a7a98 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -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:5cce28b241bb6243d535029b5091d4a546fd90eee9a00fbf2702b450fab67a2d) +// Input contract: 0.1.0 (sha256:c76b20e52333efb49a28f9db63d711561bfdc2871e98fd3cbf00d364f2992c21) // 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 diff --git a/web/workspace/src/lib/generated/workdir-api.ts b/web/workspace/src/lib/generated/workdir-api.ts index 8b375ad40..0a9fa22ff 100644 --- a/web/workspace/src/lib/generated/workdir-api.ts +++ b/web/workspace/src/lib/generated/workdir-api.ts @@ -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:5cce28b241bb6243d535029b5091d4a546fd90eee9a00fbf2702b450fab67a2d) +// Input contract: 0.1.0 (sha256:c76b20e52333efb49a28f9db63d711561bfdc2871e98fd3cbf00d364f2992c21) // 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 diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index 33a39ea29..3728989a6 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -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:5cce28b241bb6243d535029b5091d4a546fd90eee9a00fbf2702b450fab67a2d) +// Input contract: 0.1.0 (sha256:c76b20e52333efb49a28f9db63d711561bfdc2871e98fd3cbf00d364f2992c21) // 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