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)]
#[non_exhaustive]
pub struct ParamSpec {
pub name: String,
pub location: ParamLocation,
pub required: bool,
pub description: Option<String>,
pub param_type: String, pub array: bool,
pub format: Option<String>,
pub minimum: Option<f64>,
}
impl ParamSpec {
#[must_use]
pub fn path(name: impl Into<String>) -> Self {
Self::new(name, ParamLocation::Path, true)
}
#[must_use]
pub fn query(name: impl Into<String>) -> Self {
Self::new(name, ParamLocation::Query, false)
}
#[must_use]
pub fn header(name: impl Into<String>) -> Self {
Self::new(name, ParamLocation::Header, false)
}
#[must_use]
pub fn cookie(name: impl Into<String>) -> Self {
Self::new(name, ParamLocation::Cookie, false)
}
fn new(name: impl Into<String>, location: ParamLocation, required: bool) -> Self {
Self {
name: name.into(),
location,
required,
description: None,
param_type: "string".to_owned(),
array: false,
format: None,
minimum: None,
}
}
#[must_use]
pub fn required(mut self, required: bool) -> Self {
self.required = required;
self
}
#[must_use]
pub fn description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
#[must_use]
pub fn param_type(mut self, param_type: impl Into<String>) -> Self {
self.param_type = param_type.into();
self
}
#[must_use]
pub fn array(mut self, array: bool) -> Self {
self.array = array;
self
}
#[must_use]
pub fn format(mut self, format: impl Into<String>) -> Self {
self.format = Some(format.into());
self
}
#[must_use]
pub fn minimum(mut self, minimum: f64) -> Self {
self.minimum = Some(minimum);
self
}
}
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,
}
}
}
#[non_exhaustive]
#[derive(Clone, Debug)]
pub struct ResponseSpec {
pub status: u16,
pub content_type: &'static str,
pub description: String,
pub schema: Option<ResponseSchema>,
pub headers: Vec<ResponseHeaderSpec>,
}
impl ResponseSpec {
#[must_use]
pub fn new(
status: u16,
content_type: &'static str,
description: impl Into<String>,
schema: Option<ResponseSchema>,
) -> Self {
Self {
status,
content_type,
description: description.into(),
schema,
headers: Vec::new(),
}
}
#[must_use]
pub fn with_headers(mut self, headers: impl IntoIterator<Item = ResponseHeaderSpec>) -> Self {
let headers: Vec<_> = headers.into_iter().collect();
for (index, header) in headers.iter().enumerate() {
assert!(
!self
.headers
.iter()
.chain(headers[..index].iter())
.any(|existing| existing.name.eq_ignore_ascii_case(&header.name)),
"response {} already declares header '{}'",
self.status,
header.name
);
}
self.headers.extend(headers);
self
}
#[must_use]
pub fn schema_name(&self) -> Option<&str> {
self.schema.as_ref().map(ResponseSchema::schema_name)
}
}
#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ResponseHeaderType {
String,
Integer,
Boolean,
}
#[non_exhaustive]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResponseHeaderSpec {
pub name: String,
pub description: Option<String>,
pub header_type: ResponseHeaderType,
}
impl ResponseHeaderSpec {
#[must_use]
pub fn new(
name: impl Into<String>,
description: impl Into<String>,
header_type: ResponseHeaderType,
) -> Self {
Self {
name: name.into(),
description: Some(description.into()),
header_type,
}
}
#[must_use]
pub fn without_description(name: impl Into<String>, header_type: ResponseHeaderType) -> Self {
Self {
name: name.into(),
description: None,
header_type,
}
}
}
#[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 throttling: Option<ThrottlingSpec>,
pub allowed_request_content_types: Option<Vec<&'static str>>,
pub vendor_extensions: VendorExtensions,
pub license_requirement: Option<LicenseReqSpec>,
}
impl OperationSpec {
fn upsert_response(&mut self, mut response: ResponseSpec) {
let Some(index) = self.responses.iter().position(|existing| {
existing.status == response.status && existing.content_type == response.content_type
}) else {
self.responses.push(response);
return;
};
let mut existing = self.responses.remove(index);
response.headers.append(&mut existing.headers);
self.responses.push(response);
}
}
#[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 ThrottlingSpec {
pub rate_limit_zone: Option<String>,
pub in_flight_limit_zone: Option<String>,
pub require_security_context: bool,
pub dry_run: bool,
}
#[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::FilterOp;
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> = [
FilterOp::Eq,
FilterOp::Ne,
FilterOp::Gt,
FilterOp::Ge,
FilterOp::Lt,
FilterOp::Le,
FilterOp::Contains,
FilterOp::StartsWith,
FilterOp::EndsWith,
FilterOp::In,
]
.into_iter()
.filter(|op| kind.allows(*op))
.map(|op| op.to_string())
.collect();
_ = write!(description, "\n- {}: {}", name, ops.join("|"));
filter.allowed_fields.insert(name.clone(), ops);
}
self.spec
.params
.push(ParamSpec::query("$filter").description(description));
self.spec.vendor_extensions.x_odata_filter = Some(filter);
self
}
fn with_odata_select(mut self) -> Self {
self.spec
.params
.push(ParamSpec::query("$select").description("OData v4 select expression"));
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::query("$orderby").description(description));
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,
throttling: 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 with_throttling(mut self, spec: ThrottlingSpec) -> Self {
self.spec.throttling = Some(spec);
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::path(name).description(description));
self
}
pub fn query_param(
mut self,
name: impl Into<String>,
required: bool,
description: impl Into<String>,
) -> Self {
self.spec.params.push(
ParamSpec::query(name)
.required(required)
.description(description),
);
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::query(name)
.required(required)
.description(description)
.param_type(param_type),
);
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::query(p.name)
.required(p.required)
.param_type(p.openapi_type)
.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::query(name)
.required(required)
.description(description)
.param_type(item_type)
.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,
headers: Vec::new(),
});
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,
headers: Vec::new(),
});
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 }),
headers: Vec::new(),
});
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 }),
headers: Vec::new(),
});
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,
headers: Vec::new(),
});
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,
headers: Vec::new(),
});
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,
}),
headers: Vec::new(),
});
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 }),
headers: Vec::new(),
});
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 multipart_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: "multipart/mixed",
description: description.into(),
schema: Some(ResponseSchema::Ref { schema_name: name }),
headers: Vec::new(),
});
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 response_header(mut self, header: ResponseHeaderSpec) -> Self {
let Some(response) = self.spec.responses.last() else {
unreachable!("Present response state guarantees a response");
};
let status = response.status;
assert!(
!self.spec.responses.iter().any(|response| {
response.status == status
&& response
.headers
.iter()
.any(|existing| existing.name.eq_ignore_ascii_case(&header.name))
}),
"response {status} already declares header '{}'",
header.name
);
let Some(response) = self.spec.responses.last_mut() else {
unreachable!("Present response state guarantees a response");
};
response.headers.push(header);
self
}
pub fn json_response(
mut self,
status: http::StatusCode,
description: impl Into<String>,
) -> Self {
self.spec.upsert_response(ResponseSpec {
status: status.as_u16(),
content_type: "application/json",
description: description.into(),
schema: None,
headers: Vec::new(),
});
self
}
pub fn no_content_response(
mut self,
status: http::StatusCode,
description: impl Into<String>,
) -> Self {
self.spec.upsert_response(ResponseSpec {
status: status.as_u16(),
content_type: "",
description: description.into(),
schema: None,
headers: Vec::new(),
});
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.upsert_response(ResponseSpec {
status: status.as_u16(),
content_type: "application/json",
description: description.into(),
schema: Some(ResponseSchema::Ref { schema_name: name }),
headers: Vec::new(),
});
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.upsert_response(ResponseSpec {
status: status.as_u16(),
content_type: "application/json",
description: description.into(),
schema: Some(ResponseSchema::Array { items_schema_name }),
headers: Vec::new(),
});
self
}
pub fn text_response(
mut self,
status: http::StatusCode,
description: impl Into<String>,
content_type: &'static str,
) -> Self {
self.spec.upsert_response(ResponseSpec {
status: status.as_u16(),
content_type,
description: description.into(),
schema: None,
headers: Vec::new(),
});
self
}
pub fn html_response(
mut self,
status: http::StatusCode,
description: impl Into<String>,
) -> Self {
self.spec.upsert_response(ResponseSpec {
status: status.as_u16(),
content_type: "text/html",
description: description.into(),
schema: None,
headers: Vec::new(),
});
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.upsert_response(ResponseSpec {
status: status.as_u16(),
content_type: problem::APPLICATION_PROBLEM_JSON,
description: description.into(),
schema: Some(ResponseSchema::Ref {
schema_name: problem_name,
}),
headers: Vec::new(),
});
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.upsert_response(ResponseSpec {
status: http::StatusCode::OK.as_u16(),
content_type: "text/event-stream",
description: description.into(),
schema: Some(ResponseSchema::Ref { schema_name: name }),
headers: Vec::new(),
});
self
}
pub fn multipart_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.upsert_response(ResponseSpec {
status: http::StatusCode::OK.as_u16(),
content_type: "multipart/mixed",
description: description.into(),
schema: Some(ResponseSchema::Ref { schema_name: name }),
headers: Vec::new(),
});
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.upsert_response(ResponseSpec {
status: status.as_u16(),
content_type: problem::APPLICATION_PROBLEM_JSON,
description: description.to_owned(),
schema: Some(ResponseSchema::Ref {
schema_name: problem_name.clone(),
}),
headers: Vec::new(),
});
}
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.upsert_response(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,
}),
headers: Vec::new(),
});
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_413(self, registry: &dyn OpenApiRegistry) -> Self {
self.problem_response(
registry,
http::StatusCode::PAYLOAD_TOO_LARGE,
"Payload Too Large",
)
}
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))]
#[path = "operation_builder_tests.rs"]
mod tests;