feat: add typed conditional API responses
This commit is contained in:
@@ -105,6 +105,7 @@ struct RouteArgs {
|
|||||||
operation_id: Option<LitStr>,
|
operation_id: Option<LitStr>,
|
||||||
status: Option<LitInt>,
|
status: Option<LitInt>,
|
||||||
alternate_status: Option<LitInt>,
|
alternate_status: Option<LitInt>,
|
||||||
|
responses: Vec<DeclaredResponse>,
|
||||||
error_status: Option<LitInt>,
|
error_status: Option<LitInt>,
|
||||||
additional_error_statuses: Vec<LitInt>,
|
additional_error_statuses: Vec<LitInt>,
|
||||||
bearer_auth: Option<syn::LitBool>,
|
bearer_auth: Option<syn::LitBool>,
|
||||||
@@ -113,6 +114,86 @@ struct RouteArgs {
|
|||||||
openapi: Option<syn::LitBool>,
|
openapi: Option<syn::LitBool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct DeclaredResponseHeader {
|
||||||
|
name: LitStr,
|
||||||
|
field_ident: Ident,
|
||||||
|
ty: Type,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DeclaredResponse {
|
||||||
|
status: LitInt,
|
||||||
|
body: Option<Type>,
|
||||||
|
headers: Vec<DeclaredResponseHeader>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Parse for DeclaredResponse {
|
||||||
|
fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
|
||||||
|
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::<Token![=]>()?;
|
||||||
|
match key.to_string().as_str() {
|
||||||
|
"status" => set_once(
|
||||||
|
&mut status,
|
||||||
|
content.parse::<LitInt>()?,
|
||||||
|
&key,
|
||||||
|
"response status",
|
||||||
|
)?,
|
||||||
|
"body" => set_once(&mut body, content.parse::<Type>()?, &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::<LitStr>()?;
|
||||||
|
pair.parse::<Token![,]>()?;
|
||||||
|
let ty = pair.parse::<Type>()?;
|
||||||
|
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::<Token![,]>()?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
key.span(),
|
||||||
|
"unsupported response option; expected status, body, or headers",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !content.is_empty() {
|
||||||
|
content.parse::<Token![,]>()?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
status: status.ok_or_else(|| input.error("declared response requires `status`"))?,
|
||||||
|
body,
|
||||||
|
headers,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Parse for RouteArgs {
|
impl Parse for RouteArgs {
|
||||||
fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
|
fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
|
||||||
let path = input.parse()?;
|
let path = input.parse()?;
|
||||||
@@ -121,6 +202,7 @@ impl Parse for RouteArgs {
|
|||||||
operation_id: None,
|
operation_id: None,
|
||||||
status: None,
|
status: None,
|
||||||
alternate_status: None,
|
alternate_status: None,
|
||||||
|
responses: Vec::new(),
|
||||||
error_status: None,
|
error_status: None,
|
||||||
additional_error_statuses: Vec::new(),
|
additional_error_statuses: Vec::new(),
|
||||||
bearer_auth: None,
|
bearer_auth: None,
|
||||||
@@ -150,6 +232,26 @@ impl Parse for RouteArgs {
|
|||||||
&key,
|
&key,
|
||||||
"alternate_status",
|
"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::<DeclaredResponse, Token![,]>::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(
|
"error_status" => set_once(
|
||||||
&mut result.error_status,
|
&mut result.error_status,
|
||||||
input.parse::<LitInt>()?,
|
input.parse::<LitInt>()?,
|
||||||
@@ -199,7 +301,7 @@ impl Parse for RouteArgs {
|
|||||||
_ => {
|
_ => {
|
||||||
return Err(syn::Error::new(
|
return Err(syn::Error::new(
|
||||||
key.span(),
|
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,
|
wire_kind: RequestWireKind,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ResponseHeader {
|
||||||
|
wire_name: String,
|
||||||
|
field_ident: Ident,
|
||||||
|
ty: Type,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SuccessResponse {
|
||||||
|
status: u16,
|
||||||
|
body: Option<Type>,
|
||||||
|
headers: Vec<ResponseHeader>,
|
||||||
|
}
|
||||||
|
|
||||||
struct Operation {
|
struct Operation {
|
||||||
method_ident: Ident,
|
method_ident: Ident,
|
||||||
marker_ident: Ident,
|
marker_ident: Ident,
|
||||||
@@ -272,6 +386,8 @@ struct Operation {
|
|||||||
parameters: Vec<Parameter>,
|
parameters: Vec<Parameter>,
|
||||||
request_body: Option<RequestBody>,
|
request_body: Option<RequestBody>,
|
||||||
response_body: Option<Type>,
|
response_body: Option<Type>,
|
||||||
|
success_responses: Vec<SuccessResponse>,
|
||||||
|
declared_responses: bool,
|
||||||
error_body: Option<Type>,
|
error_body: Option<Type>,
|
||||||
fallible: bool,
|
fallible: bool,
|
||||||
response_status: u16,
|
response_status: u16,
|
||||||
@@ -461,31 +577,130 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result<Operation> {
|
|||||||
validate_named_body_type(ty, "error response")?;
|
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::<u16>().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")?
|
let response_status = parse_status(route.status.as_ref(), "status")?
|
||||||
.unwrap_or(if response_body.is_some() { 200 } else { 204 });
|
.unwrap_or(if response_body.is_some() { 200 } else { 204 });
|
||||||
if !(200..=299).contains(&response_status) {
|
let mut alternate_status = parse_status(route.alternate_status.as_ref(), "alternate_status")?;
|
||||||
return Err(syn::Error::new(
|
if !declared_responses {
|
||||||
route
|
if !(200..=299).contains(&response_status) {
|
||||||
.status
|
return Err(syn::Error::new(
|
||||||
.as_ref()
|
route
|
||||||
.map_or(route.path.span(), Spanned::span),
|
.status
|
||||||
"status must be a success status between 200 and 299",
|
.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)
|
if let Some(status) = alternate_status
|
||||||
{
|
&& (!(200..=299).contains(&status) || status == response_status)
|
||||||
return Err(syn::Error::new_spanned(
|
{
|
||||||
route.alternate_status,
|
return Err(syn::Error::new_spanned(
|
||||||
"alternate_status must be a distinct success status between 200 and 299",
|
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(
|
if alternate_status.is_some() && response_body.is_none() {
|
||||||
route.alternate_status,
|
return Err(syn::Error::new_spanned(
|
||||||
"alternate_status requires a response body implementing HttpSuccess",
|
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")?;
|
let error_status = parse_status(route.error_status.as_ref(), "error_status")?;
|
||||||
if error_status.is_some() && error_body.is_none() {
|
if error_status.is_some() && error_body.is_none() {
|
||||||
@@ -532,13 +747,13 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result<Operation> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
return Err(syn::Error::new_spanned(
|
||||||
&method.sig.output,
|
&method.sig.output,
|
||||||
"HEAD operations cannot declare a response body",
|
"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(
|
return Err(syn::Error::new_spanned(
|
||||||
&method.sig.output,
|
&method.sig.output,
|
||||||
format!("status {response_status} cannot have a response body"),
|
format!("status {response_status} cannot have a response body"),
|
||||||
@@ -702,6 +917,8 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result<Operation> {
|
|||||||
parameters,
|
parameters,
|
||||||
request_body: body_type,
|
request_body: body_type,
|
||||||
response_body,
|
response_body,
|
||||||
|
success_responses,
|
||||||
|
declared_responses,
|
||||||
error_body,
|
error_body,
|
||||||
fallible,
|
fallible,
|
||||||
response_status,
|
response_status,
|
||||||
@@ -876,6 +1093,71 @@ fn validate_operation_id(operation_id: &str, source: &impl Spanned) -> syn::Resu
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn response_header_field_ident(name: &LitStr) -> syn::Result<Ident> {
|
||||||
|
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::<String>();
|
||||||
|
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<Option<u16>> {
|
fn parse_status(value: Option<&LitInt>, name: &str) -> syn::Result<Option<u16>> {
|
||||||
let Some(value) = value else {
|
let Some(value) = value else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -932,6 +1214,15 @@ fn is_unit(ty: &Type) -> bool {
|
|||||||
matches!(ty, Type::Tuple(tuple) if tuple.elems.is_empty())
|
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<()> {
|
fn validate_named_body_type(ty: &Type, kind: &str) -> syn::Result<()> {
|
||||||
let Type::Path(path) = ty else {
|
let Type::Path(path) = ty else {
|
||||||
return Err(syn::Error::new_spanned(
|
return Err(syn::Error::new_spanned(
|
||||||
@@ -1083,6 +1374,11 @@ fn reqwest_adapter_tokens(
|
|||||||
let visibility = &api.item.vis;
|
let visibility = &api.item.vis;
|
||||||
let client_ident = format_ident!("{}Client", ident_text(trait_ident));
|
let client_ident = format_ident!("{}Client", ident_text(trait_ident));
|
||||||
let builder_ident = format_ident!("{}ClientBuilder", 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 methods = api.operations.iter().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| {
|
||||||
@@ -1114,10 +1410,17 @@ fn reqwest_adapter_tokens(
|
|||||||
}
|
}
|
||||||
let ident = ¶meter.rust_ident;
|
let ident = ¶meter.rust_ident;
|
||||||
let name = ¶meter.wire_name;
|
let name = ¶meter.wire_name;
|
||||||
Some(quote! {
|
if is_option_type(¶meter.ty) {
|
||||||
#api_crate::reqwest::insert_header(&mut __headers, #name, &#ident)
|
Some(quote! {
|
||||||
.map_err(#api_crate::reqwest::ClientError::from)?;
|
#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
|
let body = operation
|
||||||
.parameters
|
.parameters
|
||||||
@@ -1140,11 +1443,7 @@ fn reqwest_adapter_tokens(
|
|||||||
})
|
})
|
||||||
.unwrap_or_else(|| quote!(::core::option::Option::None));
|
.unwrap_or_else(|| quote!(::core::option::Option::None));
|
||||||
let method = reqwest_method_tokens(operation.method, api_crate);
|
let method = reqwest_method_tokens(operation.method, api_crate);
|
||||||
let response_status = operation.response_status;
|
let response_status = operation.success_responses[0].status;
|
||||||
let accepted_status = operation
|
|
||||||
.alternate_status
|
|
||||||
.map(|alternate| quote!(__actual == #response_status || __actual == #alternate))
|
|
||||||
.unwrap_or_else(|| quote!(__actual == #response_status));
|
|
||||||
let response_type = operation
|
let response_type = operation
|
||||||
.response_body
|
.response_body
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -1155,17 +1454,76 @@ fn reqwest_adapter_tokens(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|ty| quote!(#ty))
|
.map(|ty| quote!(#ty))
|
||||||
.unwrap_or_else(|| quote!(#api_crate::NoBody));
|
.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! {
|
quote! {
|
||||||
return __response
|
match __actual {
|
||||||
.decode_json::<#response>(#api_crate::reqwest::DecodeKind::Success)
|
#(#arms)*
|
||||||
.map_err(#api_crate::reqwest::ClientError::from);
|
_ => {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} 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! {
|
quote! {
|
||||||
return __response
|
if #accepted_status {
|
||||||
.require_empty()
|
#success
|
||||||
.map_err(#api_crate::reqwest::ClientError::from);
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let public_error = match operation.error_body.as_ref() {
|
let public_error = match operation.error_body.as_ref() {
|
||||||
@@ -1202,9 +1560,7 @@ fn reqwest_adapter_tokens(
|
|||||||
.await
|
.await
|
||||||
.map_err(#api_crate::reqwest::ClientError::from)?;
|
.map_err(#api_crate::reqwest::ClientError::from)?;
|
||||||
let __actual = __response.status().as_u16();
|
let __actual = __response.status().as_u16();
|
||||||
if #accepted_status {
|
#success_dispatch
|
||||||
#success
|
|
||||||
}
|
|
||||||
#public_error
|
#public_error
|
||||||
::core::result::Result::Err(#api_crate::reqwest::ClientError::from(
|
::core::result::Result::Err(#api_crate::reqwest::ClientError::from(
|
||||||
#api_crate::reqwest::ClientFailure::UnexpectedStatus {
|
#api_crate::reqwest::ClientFailure::UnexpectedStatus {
|
||||||
@@ -1355,6 +1711,11 @@ fn axum_adapter_tokens(
|
|||||||
let visibility = &api.item.vis;
|
let visibility = &api.item.vis;
|
||||||
let adapter_ident = format_ident!("{}Axum", ident_text(trait_ident));
|
let adapter_ident = format_ident!("{}Axum", ident_text(trait_ident));
|
||||||
let module_ident = format_ident!("{}_axum", to_snake_case(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 route_functions: Vec<_> = api.operations.iter().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));
|
||||||
@@ -1391,8 +1752,13 @@ fn axum_adapter_tokens(
|
|||||||
let ident = ¶meter.rust_ident;
|
let ident = ¶meter.rust_ident;
|
||||||
let ty = ¶meter.ty;
|
let ty = ¶meter.ty;
|
||||||
let name = ¶meter.wire_name;
|
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! {
|
Some(quote! {
|
||||||
let #ident: #ty = match #api_crate::axum::parse_header(&__headers, #name) {
|
let #ident: #ty = match #parser(&__headers, #name) {
|
||||||
Ok(value) => value,
|
Ok(value) => value,
|
||||||
Err(status) => return #api_crate::axum::rejection(status),
|
Err(status) => return #api_crate::axum::rejection(status),
|
||||||
};
|
};
|
||||||
@@ -1488,7 +1854,50 @@ fn axum_adapter_tokens(
|
|||||||
.unwrap_or((None, None));
|
.unwrap_or((None, None));
|
||||||
let call_arguments = operation.parameters.iter().map(|parameter| ¶meter.rust_ident);
|
let call_arguments = operation.parameters.iter().map(|parameter| ¶meter.rust_ident);
|
||||||
let response_status = operation.response_status;
|
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!({
|
quote!({
|
||||||
let status = #api_crate::HttpSuccess::status_code(&value);
|
let status = #api_crate::HttpSuccess::status_code(&value);
|
||||||
if status == #response_status || status == #alternate_status {
|
if status == #response_status || status == #alternate_status {
|
||||||
@@ -1628,23 +2037,31 @@ fn openapi_adapter_tokens(
|
|||||||
operation.binary_request_body()?;
|
operation.binary_request_body()?;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
let status = operation.response_status;
|
let success_responses = operation.success_responses.iter().enumerate().map(|(index, response)| {
|
||||||
let response = match &operation.response_body {
|
let status = response.status;
|
||||||
Some(ty) => quote! {
|
let description = if index == 0 {
|
||||||
operation.response::<#ty>(#status, "application/json", "Successful response")?;
|
"Successful response"
|
||||||
},
|
} else {
|
||||||
None => quote! {
|
"Alternate successful response"
|
||||||
operation.empty_response(#status, "Successful response")?;
|
};
|
||||||
},
|
let body = match response.body.as_ref() {
|
||||||
};
|
|
||||||
let alternate_response = operation.alternate_status.map(|status| {
|
|
||||||
match operation.response_body.as_ref() {
|
|
||||||
Some(ty) => quote! {
|
Some(ty) => quote! {
|
||||||
operation.response::<#ty>(#status, "application/json", "Alternate successful response")?;
|
operation.response::<#ty>(#status, "application/json", #description)?;
|
||||||
},
|
},
|
||||||
None => quote! {
|
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
|
let error = operation
|
||||||
@@ -1684,8 +2101,7 @@ fn openapi_adapter_tokens(
|
|||||||
#bearer_auth
|
#bearer_auth
|
||||||
#browser_auth
|
#browser_auth
|
||||||
#request
|
#request
|
||||||
#response
|
#(#success_responses)*
|
||||||
#alternate_response
|
|
||||||
#error
|
#error
|
||||||
#(#additional_errors)*
|
#(#additional_errors)*
|
||||||
operation.finish()?;
|
operation.finish()?;
|
||||||
@@ -1713,6 +2129,56 @@ fn expand_api(api: ApiDefinition) -> syn::Result<proc_macro2::TokenStream> {
|
|||||||
let reqwest_adapter = reqwest_adapter_tokens(&api, &api_crate);
|
let reqwest_adapter = reqwest_adapter_tokens(&api, &api_crate);
|
||||||
let axum_adapter = axum_adapter_tokens(&api, &api_crate);
|
let axum_adapter = axum_adapter_tokens(&api, &api_crate);
|
||||||
let openapi_adapter = openapi_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 item = api.item;
|
||||||
let metadata_ident = api.metadata_ident;
|
let metadata_ident = api.metadata_ident;
|
||||||
let module_ident = api.operations_module_ident;
|
let module_ident = api.operations_module_ident;
|
||||||
@@ -1797,6 +2263,13 @@ fn expand_api(api: ApiDefinition) -> syn::Result<proc_macro2::TokenStream> {
|
|||||||
#(#marker_items)*
|
#(#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)*
|
#(#operation_impls)*
|
||||||
|
|
||||||
#reqwest_adapter
|
#reqwest_adapter
|
||||||
@@ -1817,23 +2290,44 @@ fn metadata_tokens(
|
|||||||
Some(RequestWireKind::Binary) => quote!(#api_crate::WireKind::Binary),
|
Some(RequestWireKind::Binary) => quote!(#api_crate::WireKind::Binary),
|
||||||
None => quote!(#api_crate::WireKind::Empty),
|
None => quote!(#api_crate::WireKind::Empty),
|
||||||
};
|
};
|
||||||
let response_kind = if operation.response_body.is_some() {
|
let success_responses = operation.success_responses.iter().map(|response| {
|
||||||
quote!(#api_crate::WireKind::Json)
|
let status = response.status;
|
||||||
} else {
|
let wire_kind = if response.body.is_some() {
|
||||||
quote!(#api_crate::WireKind::Empty)
|
quote!(#api_crate::WireKind::Json)
|
||||||
};
|
} else {
|
||||||
let response_status = operation.response_status;
|
quote!(#api_crate::WireKind::Empty)
|
||||||
let error = match operation.error_status {
|
};
|
||||||
Some(status) => quote! {
|
let headers = response.headers.iter().map(|header| {
|
||||||
::core::option::Option::Some(#api_crate::ResponseMetadata {
|
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,
|
status: #status,
|
||||||
body: #api_crate::BodyMetadata {
|
body: #api_crate::BodyMetadata { wire_kind: #wire_kind },
|
||||||
wire_kind: #api_crate::WireKind::Json,
|
headers: &[#(#headers),*],
|
||||||
},
|
}
|
||||||
})
|
}
|
||||||
},
|
});
|
||||||
None => quote!(::core::option::Option::None),
|
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| {
|
let parameters = operation.parameters.iter().filter_map(|parameter| {
|
||||||
if matches!(parameter.location, Location::Extension) {
|
if matches!(parameter.location, Location::Extension) {
|
||||||
return None;
|
return None;
|
||||||
@@ -1857,11 +2351,8 @@ fn metadata_tokens(
|
|||||||
path: #path,
|
path: #path,
|
||||||
parameters: &[#(#parameters),*],
|
parameters: &[#(#parameters),*],
|
||||||
request_body: #api_crate::BodyMetadata { wire_kind: #request_kind },
|
request_body: #api_crate::BodyMetadata { wire_kind: #request_kind },
|
||||||
response: #api_crate::ResponseMetadata {
|
success_responses: &[#(#success_responses),*],
|
||||||
status: #response_status,
|
error_responses: &[#(#error_responses),*],
|
||||||
body: #api_crate::BodyMetadata { wire_kind: #response_kind },
|
|
||||||
},
|
|
||||||
error_response: #error,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
//! other application state remain explicit outer Axum layers. Each operation is also exposed as a
|
//! 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.
|
//! separate router so route-specific layers can be applied before routers are merged.
|
||||||
|
|
||||||
use std::str::FromStr;
|
use std::{fmt, str::FromStr};
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
@@ -28,6 +28,48 @@ where
|
|||||||
.map_err(|_| StatusCode::BAD_REQUEST)
|
.map_err(|_| StatusCode::BAD_REQUEST)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn parse_optional_header<T>(
|
||||||
|
headers: &HeaderMap,
|
||||||
|
name: &'static str,
|
||||||
|
) -> Result<Option<T>, 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<T: fmt::Display>(
|
||||||
|
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.
|
/// Serialize a typed JSON response with the contract status.
|
||||||
pub fn json_response<T: Serialize>(status: StatusCode, value: T) -> Response {
|
pub fn json_response<T: Serialize>(status: StatusCode, value: T) -> Response {
|
||||||
(status, Json(value)).into_response()
|
(status, Json(value)).into_response()
|
||||||
@@ -52,7 +94,7 @@ pub fn status(code: u16) -> StatusCode {
|
|||||||
pub mod framework {
|
pub mod framework {
|
||||||
pub use axum::{
|
pub use axum::{
|
||||||
Json, Router,
|
Json, Router,
|
||||||
body::Bytes,
|
body::{Body, Bytes},
|
||||||
extract::{
|
extract::{
|
||||||
DefaultBodyLimit, Extension, Path, Query, State,
|
DefaultBodyLimit, Extension, Path, Query, State,
|
||||||
rejection::{BytesRejection, JsonRejection},
|
rejection::{BytesRejection, JsonRejection},
|
||||||
@@ -63,3 +105,21 @@ pub mod framework {
|
|||||||
serve,
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,7 +39,11 @@
|
|||||||
//! method name but should be set explicitly for contracts which must remain stable while Rust
|
//! 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
|
//! 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
|
//! `204`. `alternate_status` opts a JSON response into [`HttpSuccess`] when two successful HTTP
|
||||||
//! outcomes share one schema. A public error inferred from `Result<T, E>` 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
|
||||||
|
//! `<trait_name>_responses` module. Only explicit body-less `304` declarations extend success
|
||||||
|
//! beyond 2xx. A public error inferred from `Result<T, E>` defaults to status `400`;
|
||||||
//! `additional_error_statuses` publishes the same typed error schema for other declared outcomes.
|
//! `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
|
//! `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
|
//! security schemes. Body operations may opt into typed Axum rejection normalization with
|
||||||
@@ -226,11 +230,21 @@ pub struct BodyMetadata {
|
|||||||
pub wire_kind: WireKind,
|
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.
|
/// Stable metadata for one HTTP response.
|
||||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
pub struct ResponseMetadata {
|
pub struct ResponseMetadata {
|
||||||
pub status: u16,
|
pub status: u16,
|
||||||
pub body: BodyMetadata,
|
pub body: BodyMetadata,
|
||||||
|
pub headers: &'static [ResponseHeaderMetadata],
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Complete transport metadata for one operation.
|
/// Complete transport metadata for one operation.
|
||||||
@@ -241,8 +255,10 @@ pub struct OperationMetadata {
|
|||||||
pub path: &'static str,
|
pub path: &'static str,
|
||||||
pub parameters: &'static [ParameterMetadata],
|
pub parameters: &'static [ParameterMetadata],
|
||||||
pub request_body: BodyMetadata,
|
pub request_body: BodyMetadata,
|
||||||
pub response: ResponseMetadata,
|
/// Every declared successful response, including status-specific body and header shape.
|
||||||
pub error_response: Option<ResponseMetadata>,
|
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.
|
/// Deterministic operation inventory emitted for an `#[api]` trait.
|
||||||
@@ -256,7 +272,7 @@ pub trait Operation {
|
|||||||
type Parameters;
|
type Parameters;
|
||||||
/// Typed JSON or binary request body, or [`NoBody`].
|
/// Typed JSON or binary request body, or [`NoBody`].
|
||||||
type RequestBody;
|
type RequestBody;
|
||||||
/// JSON success response body type, or [`NoBody`].
|
/// JSON success response body type, generated declared-response result, or [`NoBody`].
|
||||||
type ResponseBody;
|
type ResponseBody;
|
||||||
/// JSON public error body type, or [`NoBody`].
|
/// JSON public error body type, or [`NoBody`].
|
||||||
type ErrorBody;
|
type ErrorBody;
|
||||||
|
|||||||
@@ -427,6 +427,42 @@ impl OpenApiOperation<'_> {
|
|||||||
self.insert_response(status, json!({ "description": description }))
|
self.insert_response(status, json!({ "description": description }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn response_header<T: OpenApiSchema>(
|
||||||
|
&mut self,
|
||||||
|
status: u16,
|
||||||
|
name: &'static str,
|
||||||
|
) -> Result<(), OpenApiError> {
|
||||||
|
let schema = self.parent.schema_ref::<T>()?;
|
||||||
|
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> {
|
fn insert_response(&mut self, status: u16, response: Value) -> Result<(), OpenApiError> {
|
||||||
if !(100..=599).contains(&status) {
|
if !(100..=599).contains(&status) {
|
||||||
return Err(OpenApiError::InvalidContract(format!(
|
return Err(OpenApiError::InvalidContract(format!(
|
||||||
|
|||||||
@@ -399,6 +399,7 @@ impl<A: RequestAuthorizer> ClientCore<A> {
|
|||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
|
let headers = response.headers().clone();
|
||||||
let mut stream = response.bytes_stream();
|
let mut stream = response.bytes_stream();
|
||||||
let mut bytes = Vec::new();
|
let mut bytes = Vec::new();
|
||||||
while let Some(chunk) = stream.next().await {
|
while let Some(chunk) = stream.next().await {
|
||||||
@@ -421,7 +422,11 @@ impl<A: RequestAuthorizer> ClientCore<A> {
|
|||||||
}
|
}
|
||||||
bytes.extend_from_slice(&chunk);
|
bytes.extend_from_slice(&chunk);
|
||||||
}
|
}
|
||||||
Ok(ReceivedResponse { status, bytes })
|
Ok(ReceivedResponse {
|
||||||
|
status,
|
||||||
|
headers,
|
||||||
|
bytes,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,6 +458,18 @@ pub fn insert_header<T: fmt::Display>(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Insert an optional string-form header value when present.
|
||||||
|
pub fn insert_optional_header<T: fmt::Display>(
|
||||||
|
headers: &mut HeaderMap,
|
||||||
|
name: &str,
|
||||||
|
value: &Option<T>,
|
||||||
|
) -> 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.
|
/// Serialize a JSON request body once. These exact bytes are authorized and transmitted.
|
||||||
pub fn encode_json<T: Serialize + ?Sized>(value: &T) -> Result<EncodedBody, ClientFailure> {
|
pub fn encode_json<T: Serialize + ?Sized>(value: &T) -> Result<EncodedBody, ClientFailure> {
|
||||||
serde_json::to_vec(value)
|
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.
|
/// Response received under the configured byte limit. Its content is always redacted in Debug.
|
||||||
pub struct ReceivedResponse {
|
pub struct ReceivedResponse {
|
||||||
status: StatusCode,
|
status: StatusCode,
|
||||||
|
headers: HeaderMap,
|
||||||
bytes: Vec<u8>,
|
bytes: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,6 +494,24 @@ impl ReceivedResponse {
|
|||||||
self.status
|
self.status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn parse_header<T>(&self, name: &'static str) -> Result<T, ClientFailure>
|
||||||
|
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<T: DeserializeOwned>(&self, kind: DecodeKind) -> Result<T, ClientFailure> {
|
pub fn decode_json<T: DeserializeOwned>(&self, kind: DecodeKind) -> Result<T, ClientFailure> {
|
||||||
serde_json::from_slice(&self.bytes).map_err(|_| ClientFailure::Decode { kind })
|
serde_json::from_slice(&self.bytes).map_err(|_| ClientFailure::Decode { kind })
|
||||||
}
|
}
|
||||||
@@ -494,6 +530,7 @@ impl fmt::Debug for ReceivedResponse {
|
|||||||
formatter
|
formatter
|
||||||
.debug_struct("ReceivedResponse")
|
.debug_struct("ReceivedResponse")
|
||||||
.field("status", &self.status)
|
.field("status", &self.status)
|
||||||
|
.field("headers", &"<redacted>")
|
||||||
.field("bytes", &"<redacted>")
|
.field("bytes", &"<redacted>")
|
||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
@@ -517,6 +554,9 @@ pub enum ClientFailure {
|
|||||||
UnexpectedStatus { expected: u16, actual: u16 },
|
UnexpectedStatus { expected: u16, actual: u16 },
|
||||||
ErrorResponseDecode { status: u16 },
|
ErrorResponseDecode { status: u16 },
|
||||||
Decode { kind: DecodeKind },
|
Decode { kind: DecodeKind },
|
||||||
|
MissingResponseHeader { name: &'static str },
|
||||||
|
DuplicateResponseHeader { name: &'static str },
|
||||||
|
InvalidResponseHeader { name: &'static str },
|
||||||
UnexpectedBody,
|
UnexpectedBody,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -552,6 +592,18 @@ impl fmt::Display for ClientFailure {
|
|||||||
Self::Decode {
|
Self::Decode {
|
||||||
kind: DecodeKind::PublicError,
|
kind: DecodeKind::PublicError,
|
||||||
} => formatter.write_str("public error response JSON was malformed"),
|
} => 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"),
|
Self::UnexpectedBody => formatter.write_str("empty response contained a body"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -616,3 +668,19 @@ impl<E> std::error::Error for ClientError<E> {}
|
|||||||
pub mod framework {
|
pub mod framework {
|
||||||
pub use reqwest::{Client, Method, StatusCode, Url, header};
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,6 +43,19 @@ pub trait BinaryApi {
|
|||||||
async fn upload(&self, #[binary] payload: BinaryBody) -> Widget;
|
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]
|
#[api]
|
||||||
pub trait RawIdentifierApi {
|
pub trait RawIdentifierApi {
|
||||||
#[get("/type", operation_id = "raw.type")]
|
#[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.method, HttpMethod::Post);
|
||||||
assert_eq!(create.path, "/widgets");
|
assert_eq!(create.path, "/widgets");
|
||||||
assert_eq!(create.request_body.wire_kind, WireKind::Json);
|
assert_eq!(create.request_body.wire_kind, WireKind::Json);
|
||||||
assert_eq!(create.response.status, 201);
|
assert_eq!(create.success_responses[0].status, 201);
|
||||||
assert_eq!(create.response.body.wire_kind, WireKind::Json);
|
assert_eq!(create.success_responses[0].body.wire_kind, WireKind::Json);
|
||||||
assert_eq!(create.error_response.unwrap().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[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");
|
||||||
@@ -111,10 +124,37 @@ fn expansion_exposes_deterministic_metadata_and_type_connections() {
|
|||||||
fn assert_binary_request<O: Operation<RequestBody = BinaryBody>>() {}
|
fn assert_binary_request<O: Operation<RequestBody = BinaryBody>>() {}
|
||||||
assert_binary_request::<binary_api_operations::Upload>();
|
assert_binary_request::<binary_api_operations::Upload>();
|
||||||
|
|
||||||
|
let conditional = <conditional_api_operations::Fetch as Operation>::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<ResponseBody = conditional_api_responses::Fetch>,
|
||||||
|
>() {
|
||||||
|
}
|
||||||
|
assert_conditional_response::<conditional_api_operations::Fetch>();
|
||||||
|
|
||||||
let delete = <widget_api_operations::Delete as Operation>::METADATA;
|
let delete = <widget_api_operations::Delete as Operation>::METADATA;
|
||||||
assert_eq!(delete.response.status, 204);
|
assert_eq!(delete.success_responses[0].status, 204);
|
||||||
assert_eq!(delete.response.body.wire_kind, WireKind::Empty);
|
assert_eq!(delete.success_responses[0].body.wire_kind, WireKind::Empty);
|
||||||
assert_eq!(delete.error_response, None);
|
assert!(delete.error_responses.is_empty());
|
||||||
|
|
||||||
fn assert_empty_request<O: Operation<RequestBody = NoBody>>() {}
|
fn assert_empty_request<O: Operation<RequestBody = NoBody>>() {}
|
||||||
fn assert_empty_response<O: Operation<ResponseBody = NoBody, ErrorBody = NoBody>>() {}
|
fn assert_empty_response<O: Operation<ResponseBody = NoBody, ErrorBody = NoBody>>() {}
|
||||||
|
|||||||
@@ -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)]
|
#[api(reqwest)]
|
||||||
pub trait FailureApi {
|
pub trait FailureApi {
|
||||||
#[get("/malformed", operation_id = "failure.malformed", status = 200)]
|
#[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 {
|
async fn malformed() -> &'static str {
|
||||||
"this is not json"
|
"this is not json"
|
||||||
}
|
}
|
||||||
@@ -321,6 +420,119 @@ async fn generated_client_and_router_follow_the_normalized_contract() {
|
|||||||
server.abort();
|
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]
|
#[tokio::test]
|
||||||
async fn binary_adapter_preserves_exact_bytes_content_type_and_route_limit() {
|
async fn binary_adapter_preserves_exact_bytes_content_type_and_route_limit() {
|
||||||
let app = binary_api_axum::upload(Arc::new(BinaryService))
|
let app = binary_api_axum::upload(Arc::new(BinaryService))
|
||||||
|
|||||||
@@ -103,6 +103,16 @@ pub trait FixtureApi {
|
|||||||
|
|
||||||
#[put("/uploads", operation_id = "uploads.put", status = 200)]
|
#[put("/uploads", operation_id = "uploads.put", status = 200)]
|
||||||
async fn upload(&self, #[binary] body: BinaryBody) -> Widget;
|
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 {
|
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"
|
"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"];
|
let delete = &value["paths"]["/widgets/{widget_id}"]["delete"];
|
||||||
assert_eq!(delete["operationId"], "widgets.delete");
|
assert_eq!(delete["operationId"], "widgets.delete");
|
||||||
assert!(delete.get("security").is_none());
|
assert!(delete.get("security").is_none());
|
||||||
|
|||||||
@@ -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() {}
|
||||||
@@ -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)])]
|
||||||
|
| ^^^^^^
|
||||||
@@ -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() {}
|
||||||
@@ -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)])]
|
||||||
|
| ^^^^^^^^^^^^
|
||||||
@@ -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() {}
|
||||||
@@ -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)]
|
||||||
|
| ^^^
|
||||||
@@ -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() {}
|
||||||
@@ -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 }
|
||||||
|
|
|
||||||
@@ -154,8 +154,14 @@ impl_openapi_schema!(
|
|||||||
HealthResponse,
|
HealthResponse,
|
||||||
PasskeyLoginOptionsRequest,
|
PasskeyLoginOptionsRequest,
|
||||||
PasskeyLoginOptionsResponse,
|
PasskeyLoginOptionsResponse,
|
||||||
|
PasskeyLoginCompleteRequest,
|
||||||
PasskeyRegistrationOptionsRequest,
|
PasskeyRegistrationOptionsRequest,
|
||||||
PasskeyRegistrationOptionsResponse,
|
PasskeyRegistrationOptionsResponse,
|
||||||
|
PasskeyRegistrationCompleteRequest,
|
||||||
|
LogoutResponse,
|
||||||
|
SetCookieHeader,
|
||||||
|
WorkspaceRuntimeConfigQuery,
|
||||||
|
WorkspaceRuntimeConfigResponse,
|
||||||
WhoamiResponse,
|
WhoamiResponse,
|
||||||
WorkspaceCreateRequest,
|
WorkspaceCreateRequest,
|
||||||
WorkspaceCreateResponse,
|
WorkspaceCreateResponse,
|
||||||
@@ -412,6 +418,19 @@ pub trait ServerApi {
|
|||||||
#[body] request: PasskeyRegistrationOptionsRequest,
|
#[body] request: PasskeyRegistrationOptionsRequest,
|
||||||
) -> Result<PasskeyRegistrationOptionsResponse, RepositoryApiError>;
|
) -> Result<PasskeyRegistrationOptionsResponse, RepositoryApiError>;
|
||||||
|
|
||||||
|
#[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<server_api_responses::AuthPasskeyRegistrationComplete, RepositoryApiError>;
|
||||||
|
|
||||||
#[post(
|
#[post(
|
||||||
"/api/auth/passkeys/login/options",
|
"/api/auth/passkeys/login/options",
|
||||||
status = 200,
|
status = 200,
|
||||||
@@ -424,6 +443,19 @@ pub trait ServerApi {
|
|||||||
#[body] request: PasskeyLoginOptionsRequest,
|
#[body] request: PasskeyLoginOptionsRequest,
|
||||||
) -> Result<PasskeyLoginOptionsResponse, RepositoryApiError>;
|
) -> Result<PasskeyLoginOptionsResponse, RepositoryApiError>;
|
||||||
|
|
||||||
|
#[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<server_api_responses::AuthPasskeyLoginComplete, RepositoryApiError>;
|
||||||
|
|
||||||
#[post(
|
#[post(
|
||||||
"/api/auth/device-login/start",
|
"/api/auth/device-login/start",
|
||||||
status = 200,
|
status = 200,
|
||||||
@@ -466,6 +498,19 @@ pub trait ServerApi {
|
|||||||
#[extension] context: ServerRequestContext,
|
#[extension] context: ServerRequestContext,
|
||||||
) -> Result<WhoamiResponse, RepositoryApiError>;
|
) -> Result<WhoamiResponse, RepositoryApiError>;
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
) -> Result<server_api_responses::AuthLogout, RepositoryApiError>;
|
||||||
|
|
||||||
#[get(
|
#[get(
|
||||||
"/api/workspaces",
|
"/api/workspaces",
|
||||||
status = 200,
|
status = 200,
|
||||||
@@ -564,6 +609,23 @@ pub trait ServerApi {
|
|||||||
#[path] workspace_id: String,
|
#[path] workspace_id: String,
|
||||||
) -> Result<WorkspaceResponse, RepositoryApiError>;
|
) -> Result<WorkspaceResponse, RepositoryApiError>;
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
) -> Result<server_api_responses::WorkspaceRuntimeConfig, RepositoryApiError>;
|
||||||
|
|
||||||
#[get(
|
#[get(
|
||||||
"/api/w/{workspace_id}/settings",
|
"/api/w/{workspace_id}/settings",
|
||||||
status = 200,
|
status = 200,
|
||||||
@@ -3010,12 +3072,13 @@ pub struct PasskeyRegistrationOptionsResponse {
|
|||||||
pub public_key: CreationChallengeResponse,
|
pub public_key: CreationChallengeResponse,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct PasskeyRegistrationCompleteRequest {
|
pub struct PasskeyRegistrationCompleteRequest {
|
||||||
pub challenge_id: String,
|
pub challenge_id: String,
|
||||||
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
|
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
|
||||||
|
#[schemars(with = "serde_json::Value")]
|
||||||
pub credential: RegisterPublicKeyCredential,
|
pub credential: RegisterPublicKeyCredential,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3041,15 +3104,64 @@ pub struct PasskeyLoginOptionsResponse {
|
|||||||
pub public_key: RequestChallengeResponse,
|
pub public_key: RequestChallengeResponse,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct PasskeyLoginCompleteRequest {
|
pub struct PasskeyLoginCompleteRequest {
|
||||||
pub challenge_id: String,
|
pub challenge_id: String,
|
||||||
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
|
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
|
||||||
|
#[schemars(with = "serde_json::Value")]
|
||||||
pub credential: PublicKeyCredential,
|
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(<redacted>)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Self, Self::Err> {
|
||||||
|
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)]
|
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[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))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum LogoutStatus {
|
pub enum LogoutStatus {
|
||||||
LoggedOut,
|
LoggedOut,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct LogoutResponse {
|
pub struct LogoutResponse {
|
||||||
@@ -10587,9 +10699,12 @@ mod openapi_artifact_tests {
|
|||||||
"auth_config",
|
"auth_config",
|
||||||
"auth_bootstrap_user",
|
"auth_bootstrap_user",
|
||||||
"auth_passkey_registration_options",
|
"auth_passkey_registration_options",
|
||||||
|
"auth_passkey_registration_complete",
|
||||||
"auth_passkey_login_options",
|
"auth_passkey_login_options",
|
||||||
|
"auth_passkey_login_complete",
|
||||||
"auth_device_login_start",
|
"auth_device_login_start",
|
||||||
"auth_device_login_poll",
|
"auth_device_login_poll",
|
||||||
|
"auth_logout",
|
||||||
"auth_whoami",
|
"auth_whoami",
|
||||||
];
|
];
|
||||||
// These operations are authenticated by Runtime request-source proof, Worker source
|
// 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
|
// Session observation retains its existing explicit non-OpenAPI boundary independently of
|
||||||
// the Runtime/Worker-source signed operation inventory above.
|
// the Runtime/Worker-source signed operation inventory above.
|
||||||
const OTHER_OPENAPI_EXCLUDED: &[&str] = &["worker_session"];
|
const OTHER_OPENAPI_EXCLUDED: &[&str] = &["worker_session"];
|
||||||
|
const DOCUMENTED_SIGNED_INTERNAL: &[&str] = &["workspace_runtime_config"];
|
||||||
const BROWSER_ONLY: &[&str] = &["merge_request_selector_repair"];
|
const BROWSER_ONLY: &[&str] = &["merge_request_selector_repair"];
|
||||||
|
|
||||||
let document = canonical_openapi_document().expect("canonical OpenAPI contract must build");
|
let document = canonical_openapi_document().expect("canonical OpenAPI contract must build");
|
||||||
@@ -10667,7 +10783,9 @@ mod openapi_artifact_tests {
|
|||||||
let documented_operation = documented
|
let documented_operation = documented
|
||||||
.get(operation_id)
|
.get(operation_id)
|
||||||
.unwrap_or_else(|| panic!("missing public OpenAPI operation {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) {
|
} else if BROWSER_ONLY.contains(&operation_id) {
|
||||||
&["browserSession"][..]
|
&["browserSession"][..]
|
||||||
@@ -10705,7 +10823,7 @@ mod openapi_artifact_tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 document = canonical_openapi_document().expect("canonical OpenAPI contract must build");
|
||||||
let value: serde_json::Value =
|
let value: serde_json::Value =
|
||||||
serde_json::from_str(&document.to_json().expect("document must serialize"))
|
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/config", "get"),
|
||||||
("/api/auth/bootstrap-user", "post"),
|
("/api/auth/bootstrap-user", "post"),
|
||||||
("/api/auth/passkeys/registration/options", "post"),
|
("/api/auth/passkeys/registration/options", "post"),
|
||||||
|
("/api/auth/passkeys/registration/complete", "post"),
|
||||||
("/api/auth/passkeys/login/options", "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/start", "post"),
|
||||||
("/api/auth/device-login/approve", "post"),
|
("/api/auth/device-login/approve", "post"),
|
||||||
("/api/auth/device-login/poll", "post"),
|
("/api/auth/device-login/poll", "post"),
|
||||||
@@ -10755,10 +10876,30 @@ mod openapi_artifact_tests {
|
|||||||
"/api/auth/passkeys/login/complete",
|
"/api/auth/passkeys/login/complete",
|
||||||
"/api/auth/logout",
|
"/api/auth/logout",
|
||||||
] {
|
] {
|
||||||
assert!(
|
let response = &value["paths"][path]["post"]["responses"]["200"];
|
||||||
value["paths"].get(path).is_none(),
|
assert_eq!(
|
||||||
"cookie response route {path} must remain outside ServerApi"
|
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 [
|
for schema in [
|
||||||
"RepositoryApiError",
|
"RepositoryApiError",
|
||||||
|
|||||||
@@ -7,13 +7,15 @@ use std::sync::{Arc, Mutex, RwLock, Weak};
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
|
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
|
||||||
use axum::extract::{DefaultBodyLimit, Extension, Path as AxumPath, Query, Request, State};
|
use axum::extract::{DefaultBodyLimit, Extension, Path as AxumPath, Query, Request, State};
|
||||||
use axum::http::header::{
|
#[cfg(test)]
|
||||||
CACHE_CONTROL, CONTENT_TYPE, ETAG, IF_NONE_MATCH, LOCATION, ORIGIN, SET_COOKIE,
|
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::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri};
|
||||||
use axum::middleware::{self, Next};
|
use axum::middleware::{self, Next};
|
||||||
use axum::response::{IntoResponse, Response};
|
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 axum::{Json, Router};
|
||||||
use chrono::{Duration, SecondsFormat, Utc};
|
use chrono::{Duration, SecondsFormat, Utc};
|
||||||
use config_source::ConfigTreeSnapshot;
|
use config_source::ConfigTreeSnapshot;
|
||||||
@@ -2550,10 +2552,6 @@ pub async fn build_workspace_server_router(
|
|||||||
template: ServerConfig,
|
template: ServerConfig,
|
||||||
store: Arc<dyn ControlPlaneStore>,
|
store: Arc<dyn ControlPlaneStore>,
|
||||||
) -> Result<Router> {
|
) -> Result<Router> {
|
||||||
let auth = build_server_auth_router(ServerAuthApi {
|
|
||||||
config: template.clone(),
|
|
||||||
store: store.clone(),
|
|
||||||
});
|
|
||||||
let api = WorkspaceServerApi::new(template, store);
|
let api = WorkspaceServerApi::new(template, store);
|
||||||
api.preload().await?;
|
api.preload().await?;
|
||||||
api.recover_workspace_deletions().await?;
|
api.recover_workspace_deletions().await?;
|
||||||
@@ -2568,12 +2566,10 @@ pub async fn build_workspace_server_router(
|
|||||||
.merge(generated_workspace_catalog_contract_router(
|
.merge(generated_workspace_catalog_contract_router(
|
||||||
contract_service,
|
contract_service,
|
||||||
));
|
));
|
||||||
Ok(auth
|
Ok(catalog.layer(axum::middleware::from_fn_with_state(
|
||||||
.merge(catalog)
|
api,
|
||||||
.layer(axum::middleware::from_fn_with_state(
|
enforce_server_cookie_mutation_origin,
|
||||||
api,
|
)))
|
||||||
enforce_server_cookie_mutation_origin,
|
|
||||||
)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -3636,20 +3632,6 @@ fn configured_repository_from_record(record: RepositoryRecord) -> Result<Configu
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_server_auth_router(api: ServerAuthApi) -> 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(
|
fn server_request_context(
|
||||||
actor: Option<RequestActor>,
|
actor: Option<RequestActor>,
|
||||||
worker_source: Option<server_api::ServerWorkerSource>,
|
worker_source: Option<server_api::ServerWorkerSource>,
|
||||||
@@ -3771,6 +3753,11 @@ fn generated_auth_contract_router(service: ServerApiContractService) -> Router {
|
|||||||
.merge(server_api::server_api_axum::auth_bootstrap_user(
|
.merge(server_api::server_api_axum::auth_bootstrap_user(
|
||||||
service.clone(),
|
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(
|
.merge(server_api::server_api_axum::auth_device_login_start(
|
||||||
service.clone(),
|
service.clone(),
|
||||||
))
|
))
|
||||||
@@ -3812,6 +3799,9 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou
|
|||||||
.merge(server_api::server_api_axum::workspace_scoped(
|
.merge(server_api::server_api_axum::workspace_scoped(
|
||||||
service.clone(),
|
service.clone(),
|
||||||
))
|
))
|
||||||
|
.merge(server_api::server_api_axum::workspace_runtime_config(
|
||||||
|
service.clone(),
|
||||||
|
))
|
||||||
.merge(server_api::server_api_axum::workspace_metadata_settings(
|
.merge(server_api::server_api_axum::workspace_metadata_settings(
|
||||||
service.clone(),
|
service.clone(),
|
||||||
))
|
))
|
||||||
@@ -4592,6 +4582,23 @@ impl server_api::ServerApi for ServerApiContractService {
|
|||||||
.map_err(ApiError::into_repository_api_error)
|
.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(
|
async fn auth_passkey_login_options(
|
||||||
&self,
|
&self,
|
||||||
context: server_api::ServerRequestContext,
|
context: server_api::ServerRequestContext,
|
||||||
@@ -4606,6 +4613,23 @@ impl server_api::ServerApi for ServerApiContractService {
|
|||||||
.map_err(ApiError::into_repository_api_error)
|
.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(
|
async fn auth_device_login_start(
|
||||||
&self,
|
&self,
|
||||||
request: DeviceLoginStartRequest,
|
request: DeviceLoginStartRequest,
|
||||||
@@ -4652,6 +4676,21 @@ impl server_api::ServerApi for ServerApiContractService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn auth_logout(
|
||||||
|
&self,
|
||||||
|
cookie: Option<String>,
|
||||||
|
) -> 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(
|
async fn workspace_catalog_list(
|
||||||
&self,
|
&self,
|
||||||
context: server_api::ServerRequestContext,
|
context: server_api::ServerRequestContext,
|
||||||
@@ -4830,6 +4869,25 @@ impl server_api::ServerApi for ServerApiContractService {
|
|||||||
.map_err(ApiError::into_repository_api_error)
|
.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<String>,
|
||||||
|
) -> 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(
|
async fn workspace_metadata_settings(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
@@ -8252,8 +8310,7 @@ 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 = build_server_auth_router(ServerAuthApi::from(&api))
|
let auth = generated_auth_contract_router(contract_service.clone());
|
||||||
.merge(generated_auth_contract_router(contract_service.clone()));
|
|
||||||
let workspace = Router::new()
|
let workspace = Router::new()
|
||||||
// WebSocket provider transport is intentionally outside the generated
|
// WebSocket provider transport is intentionally outside the generated
|
||||||
// JSON REST contract.
|
// JSON REST contract.
|
||||||
@@ -8261,10 +8318,6 @@ fn build_inner_router(api: WorkspaceApi) -> Router {
|
|||||||
"/api/w/{workspace_id}/external-workdir-grants/{grant_id}/provider",
|
"/api/w/{workspace_id}/external-workdir-grants/{grant_id}/provider",
|
||||||
get(scoped_external_workdir_provider_ws),
|
get(scoped_external_workdir_provider_ws),
|
||||||
)
|
)
|
||||||
.route(
|
|
||||||
"/api/w/{workspace_id}/runtime-config",
|
|
||||||
get(get_latest_workspace_runtime_config),
|
|
||||||
)
|
|
||||||
.route(
|
.route(
|
||||||
"/api/w/{workspace_id}/protocol/ws",
|
"/api/w/{workspace_id}/protocol/ws",
|
||||||
get(scoped_workspace_protocol_ws),
|
get(scoped_workspace_protocol_ws),
|
||||||
@@ -14720,99 +14773,104 @@ async fn scoped_list_hosts(
|
|||||||
list_hosts(State(api)).await
|
list_hosts(State(api)).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
fn workspace_runtime_config_result(
|
||||||
struct LatestWorkspaceRuntimeConfigQuery {
|
api: &WorkspaceApi,
|
||||||
profile: String,
|
context: server_api::ServerRequestContext,
|
||||||
}
|
workspace_id: &str,
|
||||||
|
query: server_api::WorkspaceRuntimeConfigQuery,
|
||||||
async fn get_latest_workspace_runtime_config(
|
if_none_match: Option<String>,
|
||||||
State(api): State<WorkspaceApi>,
|
) -> std::result::Result<
|
||||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
server_api::server_api_responses::WorkspaceRuntimeConfig,
|
||||||
Query(query): Query<LatestWorkspaceRuntimeConfigQuery>,
|
server_api::RepositoryApiError,
|
||||||
headers: HeaderMap,
|
> {
|
||||||
source: Option<Extension<crate::worker_source::VerifiedRuntimeRequestSource>>,
|
if context.runtime_source.is_none() {
|
||||||
) -> Response {
|
return Err(server_api::RepositoryApiError::new(
|
||||||
let Some(Extension(_source)) = source else {
|
StatusCode::FORBIDDEN.as_u16(),
|
||||||
return (
|
"runtime_config_source_required",
|
||||||
StatusCode::FORBIDDEN,
|
"runtime config requires an authenticated Runtime source",
|
||||||
Json(serde_json::json!({ "error": "runtime_config_source_required" })),
|
Vec::new(),
|
||||||
)
|
));
|
||||||
.into_response();
|
|
||||||
};
|
|
||||||
if let Err(error) = validate_workspace_scope(&api, &path.workspace_id) {
|
|
||||||
return error.into_response();
|
|
||||||
}
|
}
|
||||||
let config_state = match api.config_store.load_workspace_config(&path.workspace_id) {
|
validate_workspace_scope(api, workspace_id).map_err(ApiError::into_repository_api_error)?;
|
||||||
Ok(Some(config)) => config,
|
let config_state = api
|
||||||
Ok(None) => {
|
.config_store
|
||||||
return (
|
.load_workspace_config(workspace_id)
|
||||||
StatusCode::NOT_FOUND,
|
.map_err(|error| ApiError::from(error).into_repository_api_error())?
|
||||||
Json(serde_json::json!({ "error": "workspace_config_not_found" })),
|
.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();
|
})?;
|
||||||
}
|
let profile_projection = crate::profile_settings::project_profiles_from_workspace_config(
|
||||||
Err(error) => return ApiError::from(error).into_response(),
|
workspace_id,
|
||||||
};
|
|
||||||
let profile_projection = match crate::profile_settings::project_profiles_from_workspace_config(
|
|
||||||
&path.workspace_id,
|
|
||||||
&config_state,
|
&config_state,
|
||||||
) {
|
)
|
||||||
Ok(projection) => projection,
|
.map_err(|error| ApiError::from(error).into_repository_api_error())?;
|
||||||
Err(error) => return ApiError::from(error).into_response(),
|
|
||||||
};
|
|
||||||
if crate::profile_settings::selector_for_workspace_candidate(
|
if crate::profile_settings::selector_for_workspace_candidate(
|
||||||
&profile_projection,
|
&profile_projection,
|
||||||
&query.profile,
|
&query.profile,
|
||||||
)
|
)
|
||||||
.is_none()
|
.is_none()
|
||||||
{
|
{
|
||||||
return (
|
return Err(server_api::RepositoryApiError::new(
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND.as_u16(),
|
||||||
Json(serde_json::json!({ "error": "runtime_config_profile_not_found" })),
|
"runtime_config_profile_not_found",
|
||||||
)
|
"Runtime Config profile was not found",
|
||||||
.into_response();
|
Vec::new(),
|
||||||
};
|
));
|
||||||
let prompt_catalog = match api
|
}
|
||||||
|
let prompt_catalog = api
|
||||||
.prompt_projection_cache
|
.prompt_projection_cache
|
||||||
.resolve(&path.workspace_id, &config_state)
|
.resolve(workspace_id, &config_state)
|
||||||
{
|
.map_err(|error| ApiError::from(error).into_repository_api_error())?;
|
||||||
Ok(catalog) => catalog,
|
let bundle =
|
||||||
Err(error) => return ApiError::from(error).into_response(),
|
crate::profile_settings::build_virtual_profile_config_bundle_with_prompt_projection(
|
||||||
};
|
|
||||||
let Some(bundle) =
|
|
||||||
(match crate::profile_settings::build_virtual_profile_config_bundle_with_prompt_projection(
|
|
||||||
&profile_projection,
|
&profile_projection,
|
||||||
&config_state,
|
&config_state,
|
||||||
&path.workspace_id,
|
workspace_id,
|
||||||
&api.config.workspace_created_at,
|
&api.config.workspace_created_at,
|
||||||
&query.profile,
|
&query.profile,
|
||||||
prompt_catalog.as_ref(),
|
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 etag = worker_runtime::config_bundle::workspace_config_etag(&bundle.metadata.digest);
|
||||||
let mut response_headers = HeaderMap::new();
|
let header_cache_control = "no-cache".to_owned();
|
||||||
response_headers.insert(ETAG, etag.parse().expect("Workspace Config ETag is valid"));
|
if if_none_match.is_some_and(|value| value.split(',').any(|candidate| candidate.trim() == etag))
|
||||||
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))
|
|
||||||
{
|
{
|
||||||
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(
|
async fn scoped_list_runtimes(
|
||||||
@@ -18959,10 +19017,10 @@ async fn post_passkey_registration_options(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn post_passkey_registration_complete(
|
fn complete_passkey_registration(
|
||||||
State(api): State<ServerAuthApi>,
|
api: &ServerAuthApi,
|
||||||
Json(request): Json<PasskeyRegistrationCompleteRequest>,
|
request: PasskeyRegistrationCompleteRequest,
|
||||||
) -> ApiResult<Response> {
|
) -> ApiResult<(AuthUserResponse, server_api::SetCookieHeader)> {
|
||||||
let challenge = api
|
let challenge = api
|
||||||
.store
|
.store
|
||||||
.consume_auth_challenge_by_id(
|
.consume_auth_challenge_by_id(
|
||||||
@@ -19025,7 +19083,7 @@ async fn post_passkey_registration_complete(
|
|||||||
created_at: crate::auth::now_rfc3339(),
|
created_at: crate::auth::now_rfc3339(),
|
||||||
last_used_at: None,
|
last_used_at: None,
|
||||||
})?;
|
})?;
|
||||||
issue_browser_session_response(&api, user)
|
issue_browser_session(&api, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn post_passkey_login_options(
|
async fn post_passkey_login_options(
|
||||||
@@ -19079,10 +19137,10 @@ async fn post_passkey_login_options(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn post_passkey_login_complete(
|
fn complete_passkey_login(
|
||||||
State(api): State<ServerAuthApi>,
|
api: &ServerAuthApi,
|
||||||
Json(request): Json<PasskeyLoginCompleteRequest>,
|
request: PasskeyLoginCompleteRequest,
|
||||||
) -> ApiResult<Response> {
|
) -> ApiResult<(AuthUserResponse, server_api::SetCookieHeader)> {
|
||||||
let challenge = api
|
let challenge = api
|
||||||
.store
|
.store
|
||||||
.consume_auth_challenge_by_id(
|
.consume_auth_challenge_by_id(
|
||||||
@@ -19160,10 +19218,13 @@ async fn post_passkey_login_complete(
|
|||||||
created_at: stored.created_at,
|
created_at: stored.created_at,
|
||||||
last_used_at: Some(crate::auth::now_rfc3339()),
|
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<Response> {
|
fn issue_browser_session(
|
||||||
|
api: &ServerAuthApi,
|
||||||
|
user: UserRecord,
|
||||||
|
) -> ApiResult<(AuthUserResponse, server_api::SetCookieHeader)> {
|
||||||
let session_token = mint_secret("yoi_sess");
|
let session_token = mint_secret("yoi_sess");
|
||||||
api.store.create_browser_session(&BrowserSessionRecord {
|
api.store.create_browser_session(&BrowserSessionRecord {
|
||||||
session_id: new_id("session"),
|
session_id: new_id("session"),
|
||||||
@@ -19174,29 +19235,17 @@ fn issue_browser_session_response(api: &ServerAuthApi, user: UserRecord) -> ApiR
|
|||||||
revoked_at: None,
|
revoked_at: None,
|
||||||
})?;
|
})?;
|
||||||
let auth = auth_public_config(&api.config);
|
let auth = auth_public_config(&api.config);
|
||||||
let mut headers = HeaderMap::new();
|
let cookie = session_set_cookie(
|
||||||
headers.insert(
|
session_cookie_policy(&auth),
|
||||||
SET_COOKIE,
|
&session_token,
|
||||||
session_set_cookie(
|
14 * 24 * 60 * 60,
|
||||||
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}"),
|
|
||||||
)
|
|
||||||
})?,
|
|
||||||
);
|
);
|
||||||
Ok((
|
Ok((
|
||||||
headers,
|
AuthUserResponse {
|
||||||
Json(AuthUserResponse {
|
|
||||||
user: user_response(user),
|
user: user_response(user),
|
||||||
}),
|
},
|
||||||
)
|
server_api::SetCookieHeader::new(cookie),
|
||||||
.into_response())
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn post_device_login_start(
|
async fn post_device_login_start(
|
||||||
@@ -19347,35 +19396,32 @@ async fn post_device_login_poll(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn post_auth_logout(
|
fn logout_browser_session(
|
||||||
State(api): State<ServerAuthApi>,
|
api: &ServerAuthApi,
|
||||||
headers: HeaderMap,
|
cookie: Option<String>,
|
||||||
) -> ApiResult<Response> {
|
) -> ApiResult<(LogoutResponse, server_api::SetCookieHeader)> {
|
||||||
let auth = auth_public_config(&api.config);
|
let auth = auth_public_config(&api.config);
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
if let Some(cookie) = cookie {
|
||||||
|
let value = cookie.parse::<HeaderValue>().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) {
|
if let Some(session_token) = parse_cookie(&headers, &auth.cookie_name) {
|
||||||
let _ = api
|
let _ = api
|
||||||
.store
|
.store
|
||||||
.revoke_browser_session(&token_hash(&session_token), &crate::auth::now_rfc3339())?;
|
.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((
|
Ok((
|
||||||
response_headers,
|
LogoutResponse {
|
||||||
Json(LogoutResponse {
|
|
||||||
status: LogoutStatus::LoggedOut,
|
status: LogoutStatus::LoggedOut,
|
||||||
}),
|
},
|
||||||
)
|
server_api::SetCookieHeader::new(session_set_cookie(session_cookie_policy(&auth), "", 0)),
|
||||||
.into_response())
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn request_origin(headers: &HeaderMap) -> Option<String> {
|
fn request_origin(headers: &HeaderMap) -> Option<String> {
|
||||||
@@ -31586,15 +31632,30 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let auth_api = ServerAuthApi::from(&api);
|
let auth_api = ServerAuthApi::from(&api);
|
||||||
|
|
||||||
let login = issue_browser_session_response(&auth_api, user).unwrap();
|
let (_, login) = issue_browser_session(&auth_api, user).unwrap();
|
||||||
let login_cookie = login.headers().get(SET_COOKIE).unwrap().to_str().unwrap();
|
let login_cookie = login.expose();
|
||||||
assert_eq!(login_cookie.contains("; Secure"), secure, "{scheme}");
|
assert_eq!(login_cookie.contains("; Secure"), secure, "{scheme}");
|
||||||
assert!(login_cookie.contains("; HttpOnly; SameSite=Lax"));
|
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
|
.await
|
||||||
.unwrap();
|
.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_eq!(logout_cookie.contains("; Secure"), secure, "{scheme}");
|
||||||
assert!(logout_cookie.contains("Max-Age=0"));
|
assert!(logout_cookie.contains("Max-Age=0"));
|
||||||
assert!(logout_cookie.contains("; HttpOnly; SameSite=Lax"));
|
assert!(logout_cookie.contains("; HttpOnly; SameSite=Lax"));
|
||||||
|
|||||||
+352
-1
@@ -2571,6 +2571,24 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"LogoutResponse": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"status": {
|
||||||
|
"$ref": "#/components/schemas/LogoutStatus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"status"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"LogoutStatus": {
|
||||||
|
"enum": [
|
||||||
|
"logged_out"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"MarkdownText": {
|
"MarkdownText": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -4339,6 +4357,12 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"Nullable_string": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
"ObjectiveCreateRequest": {
|
"ObjectiveCreateRequest": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -4990,6 +5014,20 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"PasskeyLoginCompleteRequest": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"challenge_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"credential": true
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"challenge_id",
|
||||||
|
"credential"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"PasskeyLoginOptionsRequest": {
|
"PasskeyLoginOptionsRequest": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -5022,6 +5060,20 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"PasskeyRegistrationCompleteRequest": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"challenge_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"credential": true
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"challenge_id",
|
||||||
|
"credential"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"PasskeyRegistrationOptionsRequest": {
|
"PasskeyRegistrationOptionsRequest": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -8075,6 +8127,10 @@
|
|||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"type": "integer"
|
"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": {
|
"SetTicketRoleAssignmentRequest": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -13072,6 +13128,21 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"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": {
|
"WorkspaceRuntimeDetail": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -13393,7 +13464,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "Yoi Server API",
|
"title": "Yoi Server API",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"x-yoi-source-digest": "sha256:5cce28b241bb6243d535029b5091d4a546fd90eee9a00fbf2702b450fab67a2d"
|
"x-yoi-source-digest": "sha256:c76b20e52333efb49a28f9db63d711561bfdc2871e98fd3cbf00d364f2992c21"
|
||||||
},
|
},
|
||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"paths": {
|
"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": {
|
"/api/auth/passkeys/login/options": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "auth_passkey_login_options",
|
"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": {
|
"/api/auth/passkeys/registration/options": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "auth_passkey_registration_options",
|
"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": {
|
"/api/w/{workspace_id}/runtimes": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "runtime_list",
|
"operationId": "runtime_list",
|
||||||
|
|||||||
@@ -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:5cce28b241bb6243d535029b5091d4a546fd90eee9a00fbf2702b450fab67a2d)
|
// Input contract: 0.1.0 (sha256:c76b20e52333efb49a28f9db63d711561bfdc2871e98fd3cbf00d364f2992c21)
|
||||||
// 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: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
|
// 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:5cce28b241bb6243d535029b5091d4a546fd90eee9a00fbf2702b450fab67a2d)
|
// Input contract: 0.1.0 (sha256:c76b20e52333efb49a28f9db63d711561bfdc2871e98fd3cbf00d364f2992c21)
|
||||||
// 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: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
|
// 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:5cce28b241bb6243d535029b5091d4a546fd90eee9a00fbf2702b450fab67a2d)
|
// Input contract: 0.1.0 (sha256:c76b20e52333efb49a28f9db63d711561bfdc2871e98fd3cbf00d364f2992c21)
|
||||||
// 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:5cce28b241bb6243d535029b5091d4a546fd90eee9a00fbf2702b450fab67a2d)
|
// Input contract: 0.1.0 (sha256:c76b20e52333efb49a28f9db63d711561bfdc2871e98fd3cbf00d364f2992c21)
|
||||||
// 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