use crate::api::api_dto;
use axum::{Router, handler::Handler, routing::MethodRouter};
use http::Method;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::marker::PhantomData;
use toolkit_canonical_errors::problem;
use toolkit_gts::gts_id;
#[must_use]
pub fn normalize_to_axum_path(path: &str) -> String {
path.to_owned()
}
#[must_use]
pub fn axum_to_openapi_path(path: &str) -> String {
path.replace("{*", "{")
}
pub const CORE_GLOBAL_BASE_LICENSE_FEATURE: &str =
gts_id!("cf.core.lic.feat.v1~cf.core.global.base.v1");
pub mod state {
#[derive(Debug, Clone, Copy)]
pub struct Missing;
#[derive(Debug, Clone, Copy)]
pub struct Present;
#[derive(Debug, Clone, Copy)]
pub struct AuthNotSet;
#[derive(Debug, Clone, Copy)]
pub struct AuthSet;
#[derive(Debug, Clone, Copy)]
pub struct LicenseNotSet;
#[derive(Debug, Clone, Copy)]
pub struct LicenseSet;
}
mod sealed {
pub trait Sealed {}
pub trait SealedAuth {}
pub trait SealedLicenseReq {}
}
pub trait HandlerSlot<S>: sealed::Sealed {
type Slot;
}
pub trait AuthState: sealed::SealedAuth {}
impl sealed::Sealed for Missing {}
impl sealed::Sealed for Present {}
impl sealed::SealedAuth for state::AuthNotSet {}
impl sealed::SealedAuth for state::AuthSet {}
impl AuthState for state::AuthNotSet {}
impl AuthState for state::AuthSet {}
pub trait LicenseState: sealed::SealedLicenseReq {}
impl sealed::SealedLicenseReq for state::LicenseNotSet {}
impl sealed::SealedLicenseReq for state::LicenseSet {}
impl LicenseState for state::LicenseNotSet {}
impl LicenseState for state::LicenseSet {}
impl<S> HandlerSlot<S> for Missing {
type Slot = ();
}
impl<S> HandlerSlot<S> for Present {
type Slot = MethodRouter<S>;
}
pub use state::{AuthNotSet, AuthSet, LicenseNotSet, LicenseSet, Missing, Present};
#[derive(Clone, Debug)]
pub struct ParamSpec {
pub name: String,
pub location: ParamLocation,
pub required: bool,
pub description: Option<String>,
pub param_type: String, pub array: bool,
}
impl ParamSpec {
fn scalar(
name: String,
location: ParamLocation,
required: bool,
description: Option<String>,
param_type: String,
) -> Self {
Self {
name,
location,
required,
description,
param_type,
array: false,
}
}
}
pub trait LicenseFeature: AsRef<str> {}
impl<T: LicenseFeature + ?Sized> LicenseFeature for &T {}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ParamLocation {
Path,
Query,
Header,
Cookie,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RequestBodySchema {
Ref { schema_name: String },
MultipartFile { field_name: String },
Binary,
InlineObject,
}
#[derive(Clone, Debug)]
pub struct RequestBodySpec {
pub content_type: &'static str,
pub description: Option<String>,
pub schema: RequestBodySchema,
pub required: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ResponseSchema {
Ref { schema_name: String },
Array { items_schema_name: String },
}
impl ResponseSchema {
#[must_use]
pub fn schema_name(&self) -> &str {
match self {
Self::Ref { schema_name } => schema_name,
Self::Array { items_schema_name } => items_schema_name,
}
}
}
#[derive(Clone, Debug)]
pub struct ResponseSpec {
pub status: u16,
pub content_type: &'static str,
pub description: String,
pub schema: Option<ResponseSchema>,
}
impl ResponseSpec {
#[must_use]
pub fn schema_name(&self) -> Option<&str> {
self.schema.as_ref().map(ResponseSchema::schema_name)
}
}
#[derive(Clone, Debug)]
pub struct LicenseReqSpec {
pub license_names: Vec<String>,
}
#[derive(Clone, Debug)]
pub struct OperationSpec {
pub method: Method,
pub path: String,
pub operation_id: Option<String>,
pub summary: Option<String>,
pub description: Option<String>,
pub tags: Vec<String>,
pub params: Vec<ParamSpec>,
pub request_body: Option<RequestBodySpec>,
pub responses: Vec<ResponseSpec>,
pub handler_id: String,
pub authenticated: bool,
pub exposed: bool,
pub rate_limit: Option<RateLimitSpec>,
pub allowed_request_content_types: Option<Vec<&'static str>>,
pub vendor_extensions: VendorExtensions,
pub license_requirement: Option<LicenseReqSpec>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct VendorExtensions {
#[serde(rename = "x-odata-filter", skip_serializing_if = "Option::is_none")]
pub x_odata_filter: Option<ODataPagination<BTreeMap<String, Vec<String>>>>,
#[serde(rename = "x-odata-orderby", skip_serializing_if = "Option::is_none")]
pub x_odata_orderby: Option<ODataPagination<Vec<String>>>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct ODataPagination<T> {
#[serde(rename = "allowedFields")]
pub allowed_fields: T,
}
#[derive(Clone, Debug, Default)]
pub struct RateLimitSpec {
pub rps: u32,
pub burst: u32,
pub in_flight: u32,
}
#[derive(Clone, Debug, Deserialize, Serialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct XPagination {
pub filter_fields: BTreeMap<String, Vec<String>>,
pub order_by: Vec<String>,
}
pub trait OperationBuilderODataExt<S, H, R> {
#[must_use]
fn with_odata_filter<T>(self) -> Self
where
T: toolkit_odata::filter::FilterField;
#[must_use]
fn with_odata_select(self) -> Self;
#[must_use]
fn with_odata_orderby<T>(self) -> Self
where
T: toolkit_odata::filter::FilterField;
}
impl<S, H, R, A, L> OperationBuilderODataExt<S, H, R> for OperationBuilder<H, R, S, A, L>
where
H: HandlerSlot<S>,
A: AuthState,
L: LicenseState,
{
fn with_odata_filter<T>(mut self) -> Self
where
T: toolkit_odata::filter::FilterField,
{
use std::fmt::Write as _;
use toolkit_odata::filter::FieldKind;
let mut filter = self
.spec
.vendor_extensions
.x_odata_filter
.unwrap_or_default();
let mut description = "OData v4 filter expression".to_owned();
for field in T::FIELDS {
let name = field.name().to_owned();
let kind = field.kind();
let ops: Vec<String> = match kind {
FieldKind::String => vec!["eq", "ne", "contains", "startswith", "endswith", "in"],
FieldKind::Uuid => vec!["eq", "ne", "in"],
FieldKind::Bool => vec!["eq", "ne"],
FieldKind::I64
| FieldKind::F64
| FieldKind::Decimal
| FieldKind::DateTimeUtc
| FieldKind::Date
| FieldKind::Time => {
vec!["eq", "ne", "gt", "ge", "lt", "le", "in"]
}
}
.into_iter()
.map(String::from)
.collect();
_ = write!(description, "\n- {}: {}", name, ops.join("|"));
filter.allowed_fields.insert(name.clone(), ops);
}
self.spec.params.push(ParamSpec::scalar(
"$filter".to_owned(),
ParamLocation::Query,
false,
Some(description),
"string".to_owned(),
));
self.spec.vendor_extensions.x_odata_filter = Some(filter);
self
}
fn with_odata_select(mut self) -> Self {
self.spec.params.push(ParamSpec::scalar(
"$select".to_owned(),
ParamLocation::Query,
false,
Some("OData v4 select expression".to_owned()),
"string".to_owned(),
));
self
}
fn with_odata_orderby<T>(mut self) -> Self
where
T: toolkit_odata::filter::FilterField,
{
use std::fmt::Write as _;
let mut order_by = self
.spec
.vendor_extensions
.x_odata_orderby
.unwrap_or_default();
let mut description = "OData v4 orderby expression".to_owned();
for field in T::FIELDS {
let name = field.name().to_owned();
let asc = format!("{name} asc");
let desc = format!("{name} desc");
_ = write!(description, "\n- {asc}\n- {desc}");
if !order_by.allowed_fields.contains(&asc) {
order_by.allowed_fields.push(asc);
}
if !order_by.allowed_fields.contains(&desc) {
order_by.allowed_fields.push(desc);
}
}
self.spec.params.push(ParamSpec::scalar(
"$orderby".to_owned(),
ParamLocation::Query,
false,
Some(description),
"string".to_owned(),
));
self.spec.vendor_extensions.x_odata_orderby = Some(order_by);
self
}
}
pub use crate::api::openapi_registry::{OpenApiRegistry, ensure_schema};
#[must_use]
pub struct OperationBuilder<H = Missing, R = Missing, S = (), A = AuthNotSet, L = LicenseNotSet>
where
H: HandlerSlot<S>,
A: AuthState,
L: LicenseState,
{
spec: OperationSpec,
method_router: <H as HandlerSlot<S>>::Slot,
_has_handler: PhantomData<H>,
_has_response: PhantomData<R>,
#[allow(clippy::type_complexity)]
_state: PhantomData<fn() -> S>, _auth_state: PhantomData<A>,
_license_state: PhantomData<L>,
}
impl<S> OperationBuilder<Missing, Missing, S, AuthNotSet> {
pub fn new(method: Method, path: impl Into<String>) -> Self {
let path_str = path.into();
let handler_id = format!(
"{}:{}",
method.as_str().to_lowercase(),
path_str.replace(['/', '{', '}'], "_")
);
Self {
spec: OperationSpec {
method,
path: path_str,
operation_id: None,
summary: None,
description: None,
tags: Vec::new(),
params: Vec::new(),
request_body: None,
responses: Vec::new(),
handler_id,
authenticated: false,
exposed: false,
rate_limit: None,
allowed_request_content_types: None,
vendor_extensions: VendorExtensions::default(),
license_requirement: None,
},
method_router: (), _has_handler: PhantomData,
_has_response: PhantomData,
_state: PhantomData,
_auth_state: PhantomData,
_license_state: PhantomData,
}
}
pub fn get(path: impl Into<String>) -> Self {
let path_str = path.into();
Self::new(Method::GET, normalize_to_axum_path(&path_str))
}
pub fn post(path: impl Into<String>) -> Self {
let path_str = path.into();
Self::new(Method::POST, normalize_to_axum_path(&path_str))
}
pub fn put(path: impl Into<String>) -> Self {
let path_str = path.into();
Self::new(Method::PUT, normalize_to_axum_path(&path_str))
}
pub fn delete(path: impl Into<String>) -> Self {
let path_str = path.into();
Self::new(Method::DELETE, normalize_to_axum_path(&path_str))
}
pub fn patch(path: impl Into<String>) -> Self {
let path_str = path.into();
Self::new(Method::PATCH, normalize_to_axum_path(&path_str))
}
}
impl<H, R, S, A, L> OperationBuilder<H, R, S, A, L>
where
H: HandlerSlot<S>,
A: AuthState,
L: LicenseState,
{
pub fn spec(&self) -> &OperationSpec {
&self.spec
}
pub fn operation_id(mut self, id: impl Into<String>) -> Self {
self.spec.operation_id = Some(id.into());
self
}
pub fn require_rate_limit(&mut self, rps: u32, burst: u32, in_flight: u32) -> &mut Self {
self.spec.rate_limit = Some(RateLimitSpec {
rps,
burst,
in_flight,
});
self
}
pub fn summary(mut self, text: impl Into<String>) -> Self {
self.spec.summary = Some(text.into());
self
}
pub fn description(mut self, text: impl Into<String>) -> Self {
self.spec.description = Some(text.into());
self
}
pub fn tag(mut self, tag: impl Into<String>) -> Self {
self.spec.tags.push(tag.into());
self
}
pub fn param(mut self, param: ParamSpec) -> Self {
self.spec.params.push(param);
self
}
pub fn path_param(mut self, name: impl Into<String>, description: impl Into<String>) -> Self {
self.spec.params.push(ParamSpec::scalar(
name.into(),
ParamLocation::Path,
true,
Some(description.into()),
"string".to_owned(),
));
self
}
pub fn query_param(
mut self,
name: impl Into<String>,
required: bool,
description: impl Into<String>,
) -> Self {
self.spec.params.push(ParamSpec::scalar(
name.into(),
ParamLocation::Query,
required,
Some(description.into()),
"string".to_owned(),
));
self
}
pub fn query_param_typed(
mut self,
name: impl Into<String>,
required: bool,
description: impl Into<String>,
param_type: impl Into<String>,
) -> Self {
self.spec.params.push(ParamSpec::scalar(
name.into(),
ParamLocation::Query,
required,
Some(description.into()),
param_type.into(),
));
self
}
pub fn query_params_from<T: toolkit_contract::query::QueryParams>(mut self) -> Self {
for p in T::openapi_params() {
self.spec.params.push(ParamSpec {
name: p.name.to_owned(),
location: ParamLocation::Query,
required: p.required,
description: None,
param_type: p.openapi_type.to_owned(),
array: p.array,
});
}
self
}
pub fn query_param_array(
mut self,
name: impl Into<String>,
required: bool,
description: impl Into<String>,
item_type: impl Into<String>,
) -> Self {
self.spec.params.push(ParamSpec {
name: name.into(),
location: ParamLocation::Query,
required,
description: Some(description.into()),
param_type: item_type.into(),
array: true,
});
self
}
pub fn json_request_schema(
mut self,
schema_name: impl Into<String>,
desc: impl Into<String>,
) -> Self {
self.spec.request_body = Some(RequestBodySpec {
content_type: "application/json",
description: Some(desc.into()),
schema: RequestBodySchema::Ref {
schema_name: schema_name.into(),
},
required: true,
});
self
}
pub fn json_request_schema_no_desc(mut self, schema_name: impl Into<String>) -> Self {
self.spec.request_body = Some(RequestBodySpec {
content_type: "application/json",
description: None,
schema: RequestBodySchema::Ref {
schema_name: schema_name.into(),
},
required: true,
});
self
}
pub fn json_request<T>(
mut self,
registry: &dyn OpenApiRegistry,
desc: impl Into<String>,
) -> Self
where
T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::RequestApiDto + 'static,
{
let name = ensure_schema::<T>(registry);
self.spec.request_body = Some(RequestBodySpec {
content_type: "application/json",
description: Some(desc.into()),
schema: RequestBodySchema::Ref { schema_name: name },
required: true,
});
self
}
pub fn json_request_no_desc<T>(mut self, registry: &dyn OpenApiRegistry) -> Self
where
T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::RequestApiDto + 'static,
{
let name = ensure_schema::<T>(registry);
self.spec.request_body = Some(RequestBodySpec {
content_type: "application/json",
description: None,
schema: RequestBodySchema::Ref { schema_name: name },
required: true,
});
self
}
pub fn request_optional(mut self) -> Self {
if let Some(rb) = &mut self.spec.request_body {
rb.required = false;
}
self
}
pub fn multipart_file_request(mut self, field_name: &str, description: Option<&str>) -> Self {
self.spec.request_body = Some(RequestBodySpec {
content_type: "multipart/form-data",
description: description
.map(|s| format!("{s} (expects field '{field_name}' with file data)")),
schema: RequestBodySchema::MultipartFile {
field_name: field_name.to_owned(),
},
required: true,
});
self.spec.allowed_request_content_types = Some(vec!["multipart/form-data"]);
self
}
pub fn octet_stream_request(mut self, description: Option<&str>) -> Self {
self.spec.request_body = Some(RequestBodySpec {
content_type: "application/octet-stream",
description: description.map(str::to_owned),
schema: RequestBodySchema::Binary,
required: true,
});
self.spec.allowed_request_content_types = Some(vec!["application/octet-stream"]);
self
}
pub fn allow_content_types(mut self, types: &[&'static str]) -> Self {
self.spec.allowed_request_content_types = Some(types.to_vec());
self
}
pub fn exposed(mut self) -> Self {
self.spec.exposed = true;
self
}
}
impl<H, R, S> OperationBuilder<H, R, S, AuthSet, LicenseNotSet>
where
H: HandlerSlot<S>,
{
pub fn require_license_features<F>(
mut self,
licenses: impl IntoIterator<Item = F>,
) -> OperationBuilder<H, R, S, AuthSet, LicenseSet>
where
F: LicenseFeature,
{
let license_names: Vec<String> = licenses
.into_iter()
.map(|l| l.as_ref().to_owned())
.collect();
self.spec.license_requirement =
(!license_names.is_empty()).then_some(LicenseReqSpec { license_names });
OperationBuilder {
spec: self.spec,
method_router: self.method_router,
_has_handler: self._has_handler,
_has_response: self._has_response,
_state: self._state,
_auth_state: self._auth_state,
_license_state: PhantomData,
}
}
pub fn no_license_required(self) -> OperationBuilder<H, R, S, AuthSet, LicenseSet> {
OperationBuilder {
spec: self.spec,
method_router: self.method_router,
_has_handler: self._has_handler,
_has_response: self._has_response,
_state: self._state,
_auth_state: self._auth_state,
_license_state: PhantomData,
}
}
}
impl<H, R, S, L> OperationBuilder<H, R, S, AuthNotSet, L>
where
H: HandlerSlot<S>,
L: LicenseState,
{
pub fn authenticated(mut self) -> OperationBuilder<H, R, S, AuthSet, L> {
self.spec.authenticated = true;
OperationBuilder {
spec: self.spec,
method_router: self.method_router,
_has_handler: self._has_handler,
_has_response: self._has_response,
_state: self._state,
_auth_state: PhantomData,
_license_state: self._license_state,
}
}
pub fn anonymous(mut self) -> OperationBuilder<H, R, S, AuthSet, LicenseSet> {
self.spec.authenticated = false;
OperationBuilder {
spec: self.spec,
method_router: self.method_router,
_has_handler: self._has_handler,
_has_response: self._has_response,
_state: self._state,
_auth_state: PhantomData,
_license_state: PhantomData,
}
}
#[deprecated(
since = "0.6.21",
note = "`.public()` split into two axes; use `.anonymous().exposed()` \
(this alias forwards to exactly that)"
)]
pub fn public(self) -> OperationBuilder<H, R, S, AuthSet, LicenseSet> {
self.anonymous().exposed()
}
}
impl<R, S, A, L> OperationBuilder<Missing, R, S, A, L>
where
S: Clone + Send + Sync + 'static,
A: AuthState,
L: LicenseState,
{
pub fn handler<F, T>(self, h: F) -> OperationBuilder<Present, R, S, A, L>
where
F: Handler<T, S> + Clone + Send + 'static,
T: 'static,
{
let method_router = match self.spec.method {
Method::GET => axum::routing::get(h),
Method::POST => axum::routing::post(h),
Method::PUT => axum::routing::put(h),
Method::DELETE => axum::routing::delete(h),
Method::PATCH => axum::routing::patch(h),
_ => axum::routing::any(|| async { axum::http::StatusCode::METHOD_NOT_ALLOWED }),
};
OperationBuilder {
spec: self.spec,
method_router, _has_handler: PhantomData::<Present>,
_has_response: self._has_response,
_state: self._state,
_auth_state: self._auth_state,
_license_state: self._license_state,
}
}
pub fn method_router(self, mr: MethodRouter<S>) -> OperationBuilder<Present, R, S, A, L> {
OperationBuilder {
spec: self.spec,
method_router: mr, _has_handler: PhantomData::<Present>,
_has_response: self._has_response,
_state: self._state,
_auth_state: self._auth_state,
_license_state: self._license_state,
}
}
}
impl<H, S, A, L> OperationBuilder<H, Missing, S, A, L>
where
H: HandlerSlot<S>,
A: AuthState,
L: LicenseState,
{
pub fn response(mut self, resp: ResponseSpec) -> OperationBuilder<H, Present, S, A, L> {
self.spec.responses.push(resp);
OperationBuilder {
spec: self.spec,
method_router: self.method_router,
_has_handler: self._has_handler,
_has_response: PhantomData::<Present>,
_state: self._state,
_auth_state: self._auth_state,
_license_state: self._license_state,
}
}
pub fn json_response(
mut self,
status: http::StatusCode,
description: impl Into<String>,
) -> OperationBuilder<H, Present, S, A, L> {
self.spec.responses.push(ResponseSpec {
status: status.as_u16(),
content_type: "application/json",
description: description.into(),
schema: None,
});
OperationBuilder {
spec: self.spec,
method_router: self.method_router,
_has_handler: self._has_handler,
_has_response: PhantomData::<Present>,
_state: self._state,
_auth_state: self._auth_state,
_license_state: self._license_state,
}
}
pub fn no_content_response(
mut self,
status: http::StatusCode,
description: impl Into<String>,
) -> OperationBuilder<H, Present, S, A, L> {
self.spec.responses.push(ResponseSpec {
status: status.as_u16(),
content_type: "",
description: description.into(),
schema: None,
});
OperationBuilder {
spec: self.spec,
method_router: self.method_router,
_has_handler: self._has_handler,
_has_response: PhantomData::<Present>,
_state: self._state,
_auth_state: self._auth_state,
_license_state: self._license_state,
}
}
pub fn json_response_with_schema<T>(
mut self,
registry: &dyn OpenApiRegistry,
status: http::StatusCode,
description: impl Into<String>,
) -> OperationBuilder<H, Present, S, A, L>
where
T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
{
let name = ensure_schema::<T>(registry);
self.spec.responses.push(ResponseSpec {
status: status.as_u16(),
content_type: "application/json",
description: description.into(),
schema: Some(ResponseSchema::Ref { schema_name: name }),
});
OperationBuilder {
spec: self.spec,
method_router: self.method_router,
_has_handler: self._has_handler,
_has_response: PhantomData::<Present>,
_state: self._state,
_auth_state: self._auth_state,
_license_state: self._license_state,
}
}
pub fn json_array_response_with_schema<T>(
mut self,
registry: &dyn OpenApiRegistry,
status: http::StatusCode,
description: impl Into<String>,
) -> OperationBuilder<H, Present, S, A, L>
where
T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
{
let items_schema_name = ensure_schema::<T>(registry);
self.spec.responses.push(ResponseSpec {
status: status.as_u16(),
content_type: "application/json",
description: description.into(),
schema: Some(ResponseSchema::Array { items_schema_name }),
});
OperationBuilder {
spec: self.spec,
method_router: self.method_router,
_has_handler: self._has_handler,
_has_response: PhantomData::<Present>,
_state: self._state,
_auth_state: self._auth_state,
_license_state: self._license_state,
}
}
pub fn text_response(
mut self,
status: http::StatusCode,
description: impl Into<String>,
content_type: &'static str,
) -> OperationBuilder<H, Present, S, A, L> {
self.spec.responses.push(ResponseSpec {
status: status.as_u16(),
content_type,
description: description.into(),
schema: None,
});
OperationBuilder {
spec: self.spec,
method_router: self.method_router,
_has_handler: self._has_handler,
_has_response: PhantomData::<Present>,
_state: self._state,
_auth_state: self._auth_state,
_license_state: self._license_state,
}
}
pub fn html_response(
mut self,
status: http::StatusCode,
description: impl Into<String>,
) -> OperationBuilder<H, Present, S, A, L> {
self.spec.responses.push(ResponseSpec {
status: status.as_u16(),
content_type: "text/html",
description: description.into(),
schema: None,
});
OperationBuilder {
spec: self.spec,
method_router: self.method_router,
_has_handler: self._has_handler,
_has_response: PhantomData::<Present>,
_state: self._state,
_auth_state: self._auth_state,
_license_state: self._license_state,
}
}
pub fn problem_response(
mut self,
registry: &dyn OpenApiRegistry,
status: http::StatusCode,
description: impl Into<String>,
) -> OperationBuilder<H, Present, S, A, L> {
let problem_name = ensure_schema::<toolkit_canonical_errors::Problem>(registry);
self.spec.responses.push(ResponseSpec {
status: status.as_u16(),
content_type: problem::APPLICATION_PROBLEM_JSON,
description: description.into(),
schema: Some(ResponseSchema::Ref {
schema_name: problem_name,
}),
});
OperationBuilder {
spec: self.spec,
method_router: self.method_router,
_has_handler: self._has_handler,
_has_response: PhantomData::<Present>,
_state: self._state,
_auth_state: self._auth_state,
_license_state: self._license_state,
}
}
pub fn sse_json<T>(
mut self,
openapi: &dyn OpenApiRegistry,
description: impl Into<String>,
) -> OperationBuilder<H, Present, S, A, L>
where
T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
{
let name = ensure_schema::<T>(openapi);
self.spec.responses.push(ResponseSpec {
status: http::StatusCode::OK.as_u16(),
content_type: "text/event-stream",
description: description.into(),
schema: Some(ResponseSchema::Ref { schema_name: name }),
});
OperationBuilder {
spec: self.spec,
method_router: self.method_router,
_has_handler: self._has_handler,
_has_response: PhantomData::<Present>,
_state: self._state,
_auth_state: self._auth_state,
_license_state: self._license_state,
}
}
}
impl<H, S, A, L> OperationBuilder<H, Present, S, A, L>
where
H: HandlerSlot<S>,
A: AuthState,
L: LicenseState,
{
pub fn json_response(
mut self,
status: http::StatusCode,
description: impl Into<String>,
) -> Self {
self.spec.responses.push(ResponseSpec {
status: status.as_u16(),
content_type: "application/json",
description: description.into(),
schema: None,
});
self
}
pub fn no_content_response(
mut self,
status: http::StatusCode,
description: impl Into<String>,
) -> Self {
self.spec.responses.push(ResponseSpec {
status: status.as_u16(),
content_type: "",
description: description.into(),
schema: None,
});
self
}
pub fn json_response_with_schema<T>(
mut self,
registry: &dyn OpenApiRegistry,
status: http::StatusCode,
description: impl Into<String>,
) -> Self
where
T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
{
let name = ensure_schema::<T>(registry);
self.spec.responses.push(ResponseSpec {
status: status.as_u16(),
content_type: "application/json",
description: description.into(),
schema: Some(ResponseSchema::Ref { schema_name: name }),
});
self
}
pub fn json_array_response_with_schema<T>(
mut self,
registry: &dyn OpenApiRegistry,
status: http::StatusCode,
description: impl Into<String>,
) -> Self
where
T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
{
let items_schema_name = ensure_schema::<T>(registry);
self.spec.responses.push(ResponseSpec {
status: status.as_u16(),
content_type: "application/json",
description: description.into(),
schema: Some(ResponseSchema::Array { items_schema_name }),
});
self
}
pub fn text_response(
mut self,
status: http::StatusCode,
description: impl Into<String>,
content_type: &'static str,
) -> Self {
self.spec.responses.push(ResponseSpec {
status: status.as_u16(),
content_type,
description: description.into(),
schema: None,
});
self
}
pub fn html_response(
mut self,
status: http::StatusCode,
description: impl Into<String>,
) -> Self {
self.spec.responses.push(ResponseSpec {
status: status.as_u16(),
content_type: "text/html",
description: description.into(),
schema: None,
});
self
}
pub fn problem_response(
mut self,
registry: &dyn OpenApiRegistry,
status: http::StatusCode,
description: impl Into<String>,
) -> Self {
let problem_name = ensure_schema::<toolkit_canonical_errors::Problem>(registry);
self.spec.responses.push(ResponseSpec {
status: status.as_u16(),
content_type: problem::APPLICATION_PROBLEM_JSON,
description: description.into(),
schema: Some(ResponseSchema::Ref {
schema_name: problem_name,
}),
});
self
}
pub fn sse_json<T>(
mut self,
openapi: &dyn OpenApiRegistry,
description: impl Into<String>,
) -> Self
where
T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
{
let name = ensure_schema::<T>(openapi);
self.spec.responses.push(ResponseSpec {
status: http::StatusCode::OK.as_u16(),
content_type: "text/event-stream",
description: description.into(),
schema: Some(ResponseSchema::Ref { schema_name: name }),
});
self
}
pub fn standard_errors(mut self, registry: &dyn OpenApiRegistry) -> Self {
use http::StatusCode;
let problem_name = ensure_schema::<toolkit_canonical_errors::Problem>(registry);
let standard_errors = [
(StatusCode::BAD_REQUEST, "Bad Request"),
(StatusCode::UNAUTHORIZED, "Unauthorized"),
(StatusCode::FORBIDDEN, "Forbidden"),
(StatusCode::NOT_FOUND, "Not Found"),
(StatusCode::CONFLICT, "Conflict"),
(StatusCode::TOO_MANY_REQUESTS, "Too Many Requests"),
(StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error"),
];
for (status, description) in standard_errors {
self.spec.responses.push(ResponseSpec {
status: status.as_u16(),
content_type: problem::APPLICATION_PROBLEM_JSON,
description: description.to_owned(),
schema: Some(ResponseSchema::Ref {
schema_name: problem_name.clone(),
}),
});
}
self
}
pub fn with_400_validation_error(mut self, registry: &dyn OpenApiRegistry) -> Self {
let problem_name = ensure_schema::<toolkit_canonical_errors::Problem>(registry);
self.spec.responses.push(ResponseSpec {
status: http::StatusCode::BAD_REQUEST.as_u16(),
content_type: problem::APPLICATION_PROBLEM_JSON,
description: "Validation Error".to_owned(),
schema: Some(ResponseSchema::Ref {
schema_name: problem_name,
}),
});
self
}
pub fn error_400(self, registry: &dyn OpenApiRegistry) -> Self {
self.problem_response(registry, http::StatusCode::BAD_REQUEST, "Bad Request")
}
pub fn error_401(self, registry: &dyn OpenApiRegistry) -> Self {
self.problem_response(registry, http::StatusCode::UNAUTHORIZED, "Unauthorized")
}
pub fn error_403(self, registry: &dyn OpenApiRegistry) -> Self {
self.problem_response(registry, http::StatusCode::FORBIDDEN, "Forbidden")
}
pub fn error_404(self, registry: &dyn OpenApiRegistry) -> Self {
self.problem_response(registry, http::StatusCode::NOT_FOUND, "Not Found")
}
pub fn error_409(self, registry: &dyn OpenApiRegistry) -> Self {
self.problem_response(registry, http::StatusCode::CONFLICT, "Conflict")
}
pub fn error_415(self, registry: &dyn OpenApiRegistry) -> Self {
self.problem_response(
registry,
http::StatusCode::UNSUPPORTED_MEDIA_TYPE,
"Unsupported Media Type",
)
}
pub fn error_422(self, registry: &dyn OpenApiRegistry) -> Self {
self.problem_response(
registry,
http::StatusCode::UNPROCESSABLE_ENTITY,
"Unprocessable Entity",
)
}
pub fn error_429(self, registry: &dyn OpenApiRegistry) -> Self {
self.problem_response(
registry,
http::StatusCode::TOO_MANY_REQUESTS,
"Too Many Requests",
)
}
pub fn error_500(self, registry: &dyn OpenApiRegistry) -> Self {
self.problem_response(
registry,
http::StatusCode::INTERNAL_SERVER_ERROR,
"Internal Server Error",
)
}
pub fn error_502(self, registry: &dyn OpenApiRegistry) -> Self {
self.problem_response(registry, http::StatusCode::BAD_GATEWAY, "Bad Gateway")
}
pub fn error_503(self, registry: &dyn OpenApiRegistry) -> Self {
self.problem_response(
registry,
http::StatusCode::SERVICE_UNAVAILABLE,
"Service Unavailable",
)
}
pub fn error_504(self, registry: &dyn OpenApiRegistry) -> Self {
self.problem_response(
registry,
http::StatusCode::GATEWAY_TIMEOUT,
"Gateway Timeout",
)
}
}
impl<S> OperationBuilder<Present, Present, S, AuthSet, LicenseSet>
where
S: Clone + Send + Sync + 'static,
{
pub fn register(self, router: Router<S>, openapi: &dyn OpenApiRegistry) -> Router<S> {
openapi.register_operation(&self.spec);
router.route(&self.spec.path, self.method_router)
}
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
use axum::Json;
struct MockRegistry {
operations: std::sync::Mutex<Vec<OperationSpec>>,
schemas: std::sync::Mutex<Vec<String>>,
}
impl MockRegistry {
fn new() -> Self {
Self {
operations: std::sync::Mutex::new(Vec::new()),
schemas: std::sync::Mutex::new(Vec::new()),
}
}
}
enum TestLicenseFeatures {
FeatureA,
FeatureB,
}
impl AsRef<str> for TestLicenseFeatures {
fn as_ref(&self) -> &str {
match self {
TestLicenseFeatures::FeatureA => "feature_a",
TestLicenseFeatures::FeatureB => "feature_b",
}
}
}
impl LicenseFeature for TestLicenseFeatures {}
impl OpenApiRegistry for MockRegistry {
fn register_operation(&self, spec: &OperationSpec) {
if let Ok(mut ops) = self.operations.lock() {
ops.push(spec.clone());
}
}
fn ensure_schema_raw(
&self,
name: &str,
_schemas: Vec<(
String,
utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>,
)>,
) -> String {
let name = name.to_owned();
if let Ok(mut s) = self.schemas.lock() {
s.push(name.clone());
}
name
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
async fn test_handler() -> Json<serde_json::Value> {
Json(serde_json::json!({"status": "ok"}))
}
#[toolkit_macros::api_dto(request)]
struct SampleDtoRequest;
#[toolkit_macros::api_dto(response)]
struct SampleDtoResponse;
#[test]
fn builder_descriptive_methods() {
let builder = OperationBuilder::<Missing, Missing, (), AuthNotSet>::get("/tests/v1/test")
.operation_id("test.get")
.summary("Test endpoint")
.description("A test endpoint for validation")
.tag("test")
.path_param("id", "Test ID");
assert_eq!(builder.spec.method, Method::GET);
assert_eq!(builder.spec.path, "/tests/v1/test");
assert_eq!(builder.spec.operation_id, Some("test.get".to_owned()));
assert_eq!(builder.spec.summary, Some("Test endpoint".to_owned()));
assert_eq!(
builder.spec.description,
Some("A test endpoint for validation".to_owned())
);
assert_eq!(builder.spec.tags, vec!["test"]);
assert_eq!(builder.spec.params.len(), 1);
}
#[tokio::test]
async fn builder_with_request_response_and_handler() {
let registry = MockRegistry::new();
let router = Router::new();
let _router = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
.summary("Test endpoint")
.json_request::<SampleDtoRequest>(®istry, "optional body") .anonymous()
.handler(test_handler)
.json_response_with_schema::<SampleDtoResponse>(
®istry,
http::StatusCode::OK,
"Success response",
) .register(router, ®istry);
let ops = registry.operations.lock().unwrap();
assert_eq!(ops.len(), 1);
let op = &ops[0];
assert_eq!(op.method, Method::POST);
assert_eq!(op.path, "/tests/v1/test");
assert!(op.request_body.is_some());
assert!(op.request_body.as_ref().unwrap().required);
assert_eq!(op.responses.len(), 1);
assert_eq!(op.responses[0].status, 200);
let schemas = registry.schemas.lock().unwrap();
assert!(!schemas.is_empty());
}
#[test]
fn convenience_constructors() {
let get_builder =
OperationBuilder::<Missing, Missing, (), AuthNotSet>::get("/tests/v1/get");
assert_eq!(get_builder.spec.method, Method::GET);
assert_eq!(get_builder.spec.path, "/tests/v1/get");
let post_builder =
OperationBuilder::<Missing, Missing, (), AuthNotSet>::post("/tests/v1/post");
assert_eq!(post_builder.spec.method, Method::POST);
assert_eq!(post_builder.spec.path, "/tests/v1/post");
let put_builder =
OperationBuilder::<Missing, Missing, (), AuthNotSet>::put("/tests/v1/put");
assert_eq!(put_builder.spec.method, Method::PUT);
assert_eq!(put_builder.spec.path, "/tests/v1/put");
let delete_builder =
OperationBuilder::<Missing, Missing, (), AuthNotSet>::delete("/tests/v1/delete");
assert_eq!(delete_builder.spec.method, Method::DELETE);
assert_eq!(delete_builder.spec.path, "/tests/v1/delete");
let patch_builder =
OperationBuilder::<Missing, Missing, (), AuthNotSet>::patch("/tests/v1/patch");
assert_eq!(patch_builder.spec.method, Method::PATCH);
assert_eq!(patch_builder.spec.path, "/tests/v1/patch");
}
#[test]
fn normalize_to_axum_path_should_normalize() {
assert_eq!(
normalize_to_axum_path("/tests/v1/users/{id}"),
"/tests/v1/users/{id}"
);
assert_eq!(
normalize_to_axum_path("/tests/v1/projects/{project_id}/items/{item_id}"),
"/tests/v1/projects/{project_id}/items/{item_id}"
);
assert_eq!(
normalize_to_axum_path("/tests/v1/simple"),
"/tests/v1/simple"
);
assert_eq!(
normalize_to_axum_path("/tests/v1/users/{id}/edit"),
"/tests/v1/users/{id}/edit"
);
}
#[test]
fn axum_to_openapi_path_should_convert() {
assert_eq!(
axum_to_openapi_path("/tests/v1/users/{id}"),
"/tests/v1/users/{id}"
);
assert_eq!(
axum_to_openapi_path("/tests/v1/projects/{project_id}/items/{item_id}"),
"/tests/v1/projects/{project_id}/items/{item_id}"
);
assert_eq!(axum_to_openapi_path("/tests/v1/simple"), "/tests/v1/simple");
assert_eq!(
axum_to_openapi_path("/tests/v1/static/{*path}"),
"/tests/v1/static/{path}"
);
assert_eq!(
axum_to_openapi_path("/tests/v1/files/{*filepath}"),
"/tests/v1/files/{filepath}"
);
}
#[test]
fn path_normalization_in_constructors() {
let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/users/{id}");
assert_eq!(builder.spec.path, "/tests/v1/users/{id}");
let builder = OperationBuilder::<Missing, Missing, ()>::post(
"/tests/v1/projects/{project_id}/items/{item_id}",
);
assert_eq!(
builder.spec.path,
"/tests/v1/projects/{project_id}/items/{item_id}"
);
let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/simple");
assert_eq!(builder.spec.path, "/tests/v1/simple");
}
#[test]
fn standard_errors() {
let registry = MockRegistry::new();
let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
.anonymous()
.handler(test_handler)
.json_response(http::StatusCode::OK, "Success")
.standard_errors(®istry);
assert_eq!(builder.spec.responses.len(), 8);
let statuses: Vec<u16> = builder.spec.responses.iter().map(|r| r.status).collect();
assert!(statuses.contains(&200)); assert!(statuses.contains(&400));
assert!(statuses.contains(&401));
assert!(statuses.contains(&403));
assert!(statuses.contains(&404));
assert!(statuses.contains(&409));
assert!(!statuses.contains(&422));
assert!(statuses.contains(&429));
assert!(statuses.contains(&500));
let error_responses: Vec<_> = builder
.spec
.responses
.iter()
.filter(|r| r.status >= 400)
.collect();
for resp in error_responses {
assert_eq!(
resp.content_type,
toolkit_canonical_errors::problem::APPLICATION_PROBLEM_JSON
);
assert!(resp.schema_name().is_some());
}
}
#[test]
fn authenticated() {
let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
.authenticated()
.handler(test_handler)
.json_response(http::StatusCode::OK, "Success");
assert!(builder.spec.authenticated);
assert!(!builder.spec.exposed);
}
#[test]
fn anonymous_is_internal_by_default() {
let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
.anonymous()
.handler(test_handler)
.json_response(http::StatusCode::OK, "Success");
assert!(!builder.spec.authenticated);
assert!(!builder.spec.exposed);
}
#[test]
fn exposed_is_independent_of_auth() {
let authed = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/a")
.exposed()
.authenticated()
.handler(test_handler)
.json_response(http::StatusCode::OK, "OK");
assert!(authed.spec.authenticated);
assert!(authed.spec.exposed);
let anon = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/b")
.exposed()
.anonymous()
.handler(test_handler)
.json_response(http::StatusCode::OK, "OK");
assert!(!anon.spec.authenticated);
assert!(anon.spec.exposed);
}
#[test]
#[allow(deprecated)]
fn deprecated_public_maps_to_anonymous_and_exposed() {
let op = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/ping")
.public()
.handler(test_handler)
.json_response(http::StatusCode::OK, "OK");
assert!(!op.spec.authenticated, "public route is anonymous");
assert!(op.spec.exposed, "public route is edge-exposed");
}
#[test]
fn require_license_features_none() {
let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
.authenticated()
.require_license_features::<TestLicenseFeatures>([])
.handler(|| async {})
.json_response(http::StatusCode::OK, "OK");
assert!(builder.spec.license_requirement.is_none());
}
#[test]
fn no_license_required_transitions_and_allows_register() {
let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
.authenticated()
.no_license_required()
.handler(|| async {})
.json_response(http::StatusCode::OK, "OK");
assert!(builder.spec.license_requirement.is_none());
assert!(!builder.spec.exposed);
}
#[test]
fn require_license_features_one() {
let feature = TestLicenseFeatures::FeatureA;
let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
.authenticated()
.require_license_features([&feature])
.handler(|| async {})
.json_response(http::StatusCode::OK, "OK");
let license_req = builder
.spec
.license_requirement
.as_ref()
.expect("Should have license requirement");
assert_eq!(license_req.license_names, vec!["feature_a".to_owned()]);
}
#[test]
fn require_license_features_many() {
let feature_a = TestLicenseFeatures::FeatureA;
let feature_b = TestLicenseFeatures::FeatureB;
let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
.authenticated()
.require_license_features([&feature_a, &feature_b])
.handler(|| async {})
.json_response(http::StatusCode::OK, "OK");
let license_req = builder
.spec
.license_requirement
.as_ref()
.expect("Should have license requirement");
assert_eq!(
license_req.license_names,
vec!["feature_a".to_owned(), "feature_b".to_owned()]
);
}
#[tokio::test]
async fn public_does_not_require_license_features_and_can_register() {
let registry = MockRegistry::new();
let router = Router::new();
let _router = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
.anonymous()
.handler(test_handler)
.json_response(http::StatusCode::OK, "Success")
.register(router, ®istry);
let ops = registry.operations.lock().unwrap();
assert_eq!(ops.len(), 1);
assert!(ops[0].license_requirement.is_none());
}
#[test]
fn with_400_validation_error() {
let registry = MockRegistry::new();
let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
.anonymous()
.handler(test_handler)
.json_response(http::StatusCode::CREATED, "Created")
.with_400_validation_error(®istry);
assert_eq!(builder.spec.responses.len(), 2);
let validation_response = builder
.spec
.responses
.iter()
.find(|r| r.status == 400)
.expect("Should have 400 response");
assert_eq!(validation_response.description, "Validation Error");
assert_eq!(
validation_response.content_type,
toolkit_canonical_errors::problem::APPLICATION_PROBLEM_JSON
);
assert!(validation_response.schema_name().is_some());
}
#[test]
fn allow_content_types_with_existing_request_body() {
let registry = MockRegistry::new();
let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
.json_request::<SampleDtoRequest>(®istry, "Test request")
.allow_content_types(&["application/json", "application/xml"])
.anonymous()
.handler(test_handler)
.json_response(http::StatusCode::OK, "Success");
assert!(builder.spec.request_body.is_some());
assert!(builder.spec.allowed_request_content_types.is_some());
let allowed = builder.spec.allowed_request_content_types.as_ref().unwrap();
assert_eq!(allowed.len(), 2);
assert!(allowed.contains(&"application/json"));
assert!(allowed.contains(&"application/xml"));
}
#[test]
fn allow_content_types_without_existing_request_body() {
let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
.allow_content_types(&["multipart/form-data"])
.anonymous()
.handler(test_handler)
.json_response(http::StatusCode::OK, "Success");
assert!(builder.spec.request_body.is_none());
assert!(builder.spec.allowed_request_content_types.is_some());
let allowed = builder.spec.allowed_request_content_types.as_ref().unwrap();
assert_eq!(allowed.len(), 1);
assert!(allowed.contains(&"multipart/form-data"));
}
#[test]
fn allow_content_types_can_be_chained() {
let registry = MockRegistry::new();
let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
.operation_id("test.post")
.summary("Test endpoint")
.json_request::<SampleDtoRequest>(®istry, "Test request")
.allow_content_types(&["application/json"])
.anonymous()
.handler(test_handler)
.json_response(http::StatusCode::OK, "Success")
.problem_response(
®istry,
http::StatusCode::UNSUPPORTED_MEDIA_TYPE,
"Unsupported Media Type",
);
assert_eq!(builder.spec.operation_id, Some("test.post".to_owned()));
assert!(builder.spec.request_body.is_some());
assert!(builder.spec.allowed_request_content_types.is_some());
assert_eq!(builder.spec.responses.len(), 2);
}
#[test]
fn multipart_file_request() {
let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/upload")
.operation_id("test.upload")
.summary("Upload file")
.multipart_file_request("file", Some("Upload a file"))
.anonymous()
.handler(test_handler)
.json_response(http::StatusCode::OK, "Success");
assert!(builder.spec.request_body.is_some());
let rb = builder.spec.request_body.as_ref().unwrap();
assert_eq!(rb.content_type, "multipart/form-data");
assert!(rb.description.is_some());
assert!(rb.description.as_ref().unwrap().contains("file"));
assert!(rb.required);
assert_eq!(
rb.schema,
RequestBodySchema::MultipartFile {
field_name: "file".to_owned()
}
);
assert!(builder.spec.allowed_request_content_types.is_some());
let allowed = builder.spec.allowed_request_content_types.as_ref().unwrap();
assert_eq!(allowed.len(), 1);
assert!(allowed.contains(&"multipart/form-data"));
}
#[test]
fn multipart_file_request_without_description() {
let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/upload")
.multipart_file_request("file", None)
.anonymous()
.handler(test_handler)
.json_response(http::StatusCode::OK, "Success");
assert!(builder.spec.request_body.is_some());
let rb = builder.spec.request_body.as_ref().unwrap();
assert_eq!(rb.content_type, "multipart/form-data");
assert!(rb.description.is_none());
assert_eq!(
rb.schema,
RequestBodySchema::MultipartFile {
field_name: "file".to_owned()
}
);
}
#[test]
fn octet_stream_request() {
let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/upload")
.operation_id("test.upload")
.summary("Upload raw file")
.octet_stream_request(Some("Raw file bytes"))
.anonymous()
.handler(test_handler)
.json_response(http::StatusCode::OK, "Success");
assert!(builder.spec.request_body.is_some());
let rb = builder.spec.request_body.as_ref().unwrap();
assert_eq!(rb.content_type, "application/octet-stream");
assert_eq!(rb.description, Some("Raw file bytes".to_owned()));
assert!(rb.required);
assert_eq!(rb.schema, RequestBodySchema::Binary);
assert!(builder.spec.allowed_request_content_types.is_some());
let allowed = builder.spec.allowed_request_content_types.as_ref().unwrap();
assert_eq!(allowed.len(), 1);
assert!(allowed.contains(&"application/octet-stream"));
}
#[test]
fn octet_stream_request_without_description() {
let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/upload")
.octet_stream_request(None)
.anonymous()
.handler(test_handler)
.json_response(http::StatusCode::OK, "Success");
assert!(builder.spec.request_body.is_some());
let rb = builder.spec.request_body.as_ref().unwrap();
assert_eq!(rb.content_type, "application/octet-stream");
assert!(rb.description.is_none());
assert_eq!(rb.schema, RequestBodySchema::Binary);
}
#[test]
fn json_request_uses_ref_schema() {
let registry = MockRegistry::new();
let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
.json_request::<SampleDtoRequest>(®istry, "Test request body")
.anonymous()
.handler(test_handler)
.json_response(http::StatusCode::OK, "Success");
assert!(builder.spec.request_body.is_some());
let rb = builder.spec.request_body.as_ref().unwrap();
assert_eq!(rb.content_type, "application/json");
match &rb.schema {
RequestBodySchema::Ref { schema_name } => {
assert!(!schema_name.is_empty());
}
_ => panic!("Expected RequestBodySchema::Ref for JSON request"),
}
}
#[test]
fn response_content_types_must_not_contain_parameters() {
let registry = MockRegistry::new();
let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
.operation_id("test.content_type_purity")
.summary("Test response content types")
.json_request::<SampleDtoRequest>(®istry, "Test")
.anonymous()
.handler(test_handler)
.text_response(http::StatusCode::OK, "Text", "text/plain")
.text_response(http::StatusCode::OK, "Markdown", "text/markdown")
.html_response(http::StatusCode::OK, "HTML")
.json_response(http::StatusCode::OK, "JSON")
.problem_response(®istry, http::StatusCode::BAD_REQUEST, "Error");
for response in &builder.spec.responses {
assert!(
!response.content_type.contains(';'),
"Response content_type '{}' must not contain parameters. \
Use pure media type without charset or other parameters. \
OpenAPI media type keys cannot include parameters.",
response.content_type
);
}
}
}