toolkit/api/operation_builder.rs
1// Updated: 2026-04-28 by Constructor Tech
2//! Type-safe API operation builder with compile-time guarantees
3//!
4//! This gear implements a type-state builder pattern that ensures:
5//! - `register()` cannot be called unless a handler is set
6//! - `register()` cannot be called unless at least one response is declared
7//! - Descriptive methods remain available at any stage
8//! - No panics or unwraps in production hot paths
9//! - Request body support (`json_request`, `json_request_schema`) so POST/PUT calls are invokable in UI
10//! - Schema-aware responses (`json_response_with_schema`)
11//! - Typed Router state `S` usage pattern: pass a state type once via `Router::with_state`,
12//! then use plain function handlers (no per-route closures that capture/clones).
13//! - Optional `method_router(...)` for advanced use (layers/middleware on route level).
14
15use crate::api::api_dto;
16use axum::{Router, handler::Handler, routing::MethodRouter};
17use http::Method;
18use serde::{Deserialize, Serialize};
19use std::collections::BTreeMap;
20use std::marker::PhantomData;
21use toolkit_canonical_errors::problem;
22use toolkit_gts::gts_id;
23
24/// Convert OpenAPI-style path placeholders to Axum 0.8+ style path parameters.
25///
26/// Axum 0.8+ uses `{id}` for path parameters and `{*path}` for wildcards, which is the same as `OpenAPI`.
27/// However, `OpenAPI` wildcards are just `{path}` without the asterisk.
28/// This function converts `OpenAPI` wildcards to Axum wildcards by detecting common wildcard names.
29///
30/// # Examples
31///
32/// ```
33/// # use toolkit::api::operation_builder::normalize_to_axum_path;
34/// assert_eq!(normalize_to_axum_path("/users/{id}"), "/users/{id}");
35/// assert_eq!(normalize_to_axum_path("/projects/{project_id}/items/{item_id}"), "/projects/{project_id}/items/{item_id}");
36/// // Note: Most paths don't need normalization in Axum 0.8+
37/// ```
38#[must_use]
39pub fn normalize_to_axum_path(path: &str) -> String {
40 // In Axum 0.8+, the path syntax is {param} for parameters and {*wildcard} for wildcards
41 // which is the same as OpenAPI except wildcards need the asterisk prefix.
42 // For now, we just pass through the path as-is since OpenAPI and Axum 0.8 use the same syntax
43 // for regular parameters. Wildcards need special handling if used.
44 path.to_owned()
45}
46
47/// Convert Axum 0.8+ style path parameters to OpenAPI-style placeholders.
48///
49/// Removes the asterisk prefix from Axum wildcards `{*path}` to make them OpenAPI-compatible `{path}`.
50///
51/// # Examples
52///
53/// ```
54/// # use toolkit::api::operation_builder::axum_to_openapi_path;
55/// assert_eq!(axum_to_openapi_path("/users/{id}"), "/users/{id}");
56/// assert_eq!(axum_to_openapi_path("/static/{*path}"), "/static/{path}");
57/// ```
58#[must_use]
59pub fn axum_to_openapi_path(path: &str) -> String {
60 // In Axum 0.8+, wildcards are {*name} but OpenAPI expects {name}
61 // Regular parameters are the same in both
62 path.replace("{*", "{")
63}
64
65/// Canonical base license feature used by the example gears.
66pub const CORE_GLOBAL_BASE_LICENSE_FEATURE: &str =
67 gts_id!("cf.core.lic.feat.v1~cf.core.global.base.v1");
68
69/// Type-state markers for compile-time enforcement
70pub mod state {
71 /// Marker for missing required components
72 #[derive(Debug, Clone, Copy)]
73 pub struct Missing;
74
75 /// Marker for present required components
76 #[derive(Debug, Clone, Copy)]
77 pub struct Present;
78
79 /// Marker for auth requirement not yet set
80 #[derive(Debug, Clone, Copy)]
81 pub struct AuthNotSet;
82
83 /// Marker for auth requirement set (either `authenticated` or public)
84 #[derive(Debug, Clone, Copy)]
85 pub struct AuthSet;
86
87 /// Marker for license requirement not yet set
88 #[derive(Debug, Clone, Copy)]
89 pub struct LicenseNotSet;
90
91 /// Marker for license requirement set
92 #[derive(Debug, Clone, Copy)]
93 pub struct LicenseSet;
94}
95
96/// Internal trait mapping handler state to the concrete router slot type.
97/// For `Missing` there is no router slot; for `Present` it is `MethodRouter<S>`.
98/// Private sealed trait to enforce the implementation is only visible within this gear.
99mod sealed {
100 pub trait Sealed {}
101 pub trait SealedAuth {}
102 pub trait SealedLicenseReq {}
103}
104
105pub trait HandlerSlot<S>: sealed::Sealed {
106 type Slot;
107}
108
109/// Sealed trait for auth state markers
110pub trait AuthState: sealed::SealedAuth {}
111
112impl sealed::Sealed for Missing {}
113impl sealed::Sealed for Present {}
114
115impl sealed::SealedAuth for state::AuthNotSet {}
116impl sealed::SealedAuth for state::AuthSet {}
117
118impl AuthState for state::AuthNotSet {}
119impl AuthState for state::AuthSet {}
120
121pub trait LicenseState: sealed::SealedLicenseReq {}
122
123impl sealed::SealedLicenseReq for state::LicenseNotSet {}
124impl sealed::SealedLicenseReq for state::LicenseSet {}
125
126impl LicenseState for state::LicenseNotSet {}
127impl LicenseState for state::LicenseSet {}
128
129impl<S> HandlerSlot<S> for Missing {
130 type Slot = ();
131}
132impl<S> HandlerSlot<S> for Present {
133 type Slot = MethodRouter<S>;
134}
135
136pub use state::{AuthNotSet, AuthSet, LicenseNotSet, LicenseSet, Missing, Present};
137
138/// Parameter specification for API operations
139#[derive(Clone, Debug)]
140pub struct ParamSpec {
141 pub name: String,
142 pub location: ParamLocation,
143 pub required: bool,
144 pub description: Option<String>,
145 pub param_type: String, // JSON Schema type (string, integer, etc.)
146 /// Whether the parameter repeats. When set, `param_type` describes the
147 /// *item* type and the parameter renders as `type: array` with
148 /// `style: form, explode: true` — i.e. `?tag=a&tag=b`, which is how the
149 /// generated REST client encodes a `Vec<T>` query field.
150 pub array: bool,
151}
152
153impl ParamSpec {
154 /// A single-valued parameter of `param_type`.
155 fn scalar(
156 name: String,
157 location: ParamLocation,
158 required: bool,
159 description: Option<String>,
160 param_type: String,
161 ) -> Self {
162 Self {
163 name,
164 location,
165 required,
166 description,
167 param_type,
168 array: false,
169 }
170 }
171}
172
173pub trait LicenseFeature: AsRef<str> {}
174
175impl<T: LicenseFeature + ?Sized> LicenseFeature for &T {}
176
177#[derive(Clone, Debug, PartialEq, Eq)]
178pub enum ParamLocation {
179 Path,
180 Query,
181 Header,
182 Cookie,
183}
184
185/// Request body schema variants for different kinds of request bodies
186#[derive(Clone, Debug, PartialEq, Eq)]
187pub enum RequestBodySchema {
188 /// Reference to a component schema in `#/components/schemas/{schema_name}`
189 Ref { schema_name: String },
190 /// Multipart form with a single file field
191 MultipartFile { field_name: String },
192 /// Raw binary body (e.g. application/octet-stream), represented as
193 /// type: string, format: binary in `OpenAPI`.
194 Binary,
195 /// A generic inline object schema with no predefined properties
196 InlineObject,
197}
198
199/// Request body specification for API operations
200#[derive(Clone, Debug)]
201pub struct RequestBodySpec {
202 pub content_type: &'static str,
203 pub description: Option<String>,
204 /// The schema for this request body
205 pub schema: RequestBodySchema,
206 /// Whether request body is required (`OpenAPI` default is `false`).
207 pub required: bool,
208}
209
210/// Response body schema variants.
211///
212/// Mirrors [`RequestBodySchema`]. `Array` exists because utoipa's default
213/// `ToSchema::name()` strips generic arguments, so `Vec<A>` and `Vec<B>` both
214/// resolve to the component name `Vec` and clobber each other in
215/// `components.schemas`. A top-level array is therefore emitted **inline** —
216/// `{type: array, items: {$ref: T}}` — registering only the item type as a
217/// named component. That is both the `OpenAPI` norm and what utoipa's own
218/// `#[utoipa::path]` produces for a `Vec<T>` body.
219#[derive(Clone, Debug, PartialEq, Eq)]
220pub enum ResponseSchema {
221 /// Reference to a component schema in `#/components/schemas/{schema_name}`
222 Ref { schema_name: String },
223 /// Inline array whose items `$ref` the named item component.
224 Array { items_schema_name: String },
225}
226
227impl ResponseSchema {
228 /// The component name this response ultimately references: the type itself
229 /// for [`Self::Ref`], the item type for [`Self::Array`].
230 #[must_use]
231 pub fn schema_name(&self) -> &str {
232 match self {
233 Self::Ref { schema_name } => schema_name,
234 Self::Array { items_schema_name } => items_schema_name,
235 }
236 }
237}
238
239/// Response specification for API operations
240#[non_exhaustive]
241#[derive(Clone, Debug)]
242pub struct ResponseSpec {
243 pub status: u16,
244 pub content_type: &'static str,
245 pub description: String,
246 /// Schema of the response body (if any).
247 pub schema: Option<ResponseSchema>,
248 /// Headers that may be returned with this response.
249 pub headers: Vec<ResponseHeaderSpec>,
250}
251
252impl ResponseSpec {
253 /// Create a response specification without declared headers.
254 #[must_use]
255 pub fn new(
256 status: u16,
257 content_type: &'static str,
258 description: impl Into<String>,
259 schema: Option<ResponseSchema>,
260 ) -> Self {
261 Self {
262 status,
263 content_type,
264 description: description.into(),
265 schema,
266 headers: Vec::new(),
267 }
268 }
269
270 /// Add headers to this response specification.
271 ///
272 /// # Panics
273 /// Panics when the response already has, or the supplied headers contain,
274 /// a header with the same case-insensitive name.
275 #[must_use]
276 pub fn with_headers(mut self, headers: impl IntoIterator<Item = ResponseHeaderSpec>) -> Self {
277 let headers: Vec<_> = headers.into_iter().collect();
278 for (index, header) in headers.iter().enumerate() {
279 assert!(
280 !self
281 .headers
282 .iter()
283 .chain(headers[..index].iter())
284 .any(|existing| existing.name.eq_ignore_ascii_case(&header.name)),
285 "response {} already declares header '{}'",
286 self.status,
287 header.name
288 );
289 }
290 self.headers.extend(headers);
291 self
292 }
293
294 /// Name of the component schema this response references, if any.
295 ///
296 /// For an array response this is the **item** component, not the array.
297 #[must_use]
298 pub fn schema_name(&self) -> Option<&str> {
299 self.schema.as_ref().map(ResponseSchema::schema_name)
300 }
301}
302
303/// JSON Schema scalar type of a response header.
304#[non_exhaustive]
305#[derive(Clone, Copy, Debug, PartialEq, Eq)]
306pub enum ResponseHeaderType {
307 String,
308 Integer,
309 Boolean,
310}
311
312/// Header declared on one API response.
313#[non_exhaustive]
314#[derive(Clone, Debug, PartialEq, Eq)]
315pub struct ResponseHeaderSpec {
316 pub name: String,
317 pub description: Option<String>,
318 pub header_type: ResponseHeaderType,
319}
320
321impl ResponseHeaderSpec {
322 /// Create a response header with a description.
323 #[must_use]
324 pub fn new(
325 name: impl Into<String>,
326 description: impl Into<String>,
327 header_type: ResponseHeaderType,
328 ) -> Self {
329 Self {
330 name: name.into(),
331 description: Some(description.into()),
332 header_type,
333 }
334 }
335
336 /// Create a response header without a description.
337 #[must_use]
338 pub fn without_description(name: impl Into<String>, header_type: ResponseHeaderType) -> Self {
339 Self {
340 name: name.into(),
341 description: None,
342 header_type,
343 }
344 }
345}
346
347/// License requirement specification for an operation
348#[derive(Clone, Debug)]
349pub struct LicenseReqSpec {
350 pub license_names: Vec<String>,
351}
352
353/// Simplified operation specification for the type-safe builder
354#[derive(Clone, Debug)]
355pub struct OperationSpec {
356 pub method: Method,
357 pub path: String,
358 pub operation_id: Option<String>,
359 pub summary: Option<String>,
360 pub description: Option<String>,
361 pub tags: Vec<String>,
362 pub params: Vec<ParamSpec>,
363 pub request_body: Option<RequestBodySpec>,
364 pub responses: Vec<ResponseSpec>,
365 /// Internal handler id; can be used by registry/generator to map a handler identity
366 pub handler_id: String,
367 /// Auth axis: whether this operation requires a validated tenant JWT.
368 /// `true` = authenticated (bearer required); `false` = anonymous (a missing
369 /// bearer is allowed — a present bearer is still always re-validated).
370 /// Independent of [`exposed`](Self::exposed); maps 1:1 to the
371 /// `AnonymousRoute` marker in the `OoP` per-gear middleware (`!authenticated`).
372 pub authenticated: bool,
373 /// Visibility axis: whether this route is registered in the gateway for
374 /// external access (`true`) or is internal-only, reachable only via
375 /// inter-gear communication (`false`). Defaults to `false` (internal).
376 /// Independent of [`authenticated`](Self::authenticated) — an exposed route
377 /// may still require a JWT.
378 pub exposed: bool,
379 /// Optional rate & concurrency limits for this operation
380 pub rate_limit: Option<RateLimitSpec>,
381 /// Optional whitelist of allowed request Content-Type values (without parameters).
382 /// Example: Some(vec!["application/json", "multipart/form-data", "application/pdf"])
383 /// When set, gateway middleware will enforce these types and return HTTP 415 for
384 /// requests with disallowed Content-Type headers. This is independent of the
385 /// request body schema and should not be used to create synthetic request bodies.
386 pub allowed_request_content_types: Option<Vec<&'static str>>,
387 /// `OpenAPI` vendor extensions (x-*)
388 pub vendor_extensions: VendorExtensions,
389 pub license_requirement: Option<LicenseReqSpec>,
390}
391
392impl OperationSpec {
393 /// Replace a response with the same status and content type while retaining
394 /// headers that were already declared for that response. The declared
395 /// response becomes the most recent response so subsequent headers attach
396 /// to it.
397 ///
398 /// Different content types for the same status are kept as separate specs;
399 /// the `OpenAPI` registry combines them into one response object.
400 fn upsert_response(&mut self, mut response: ResponseSpec) {
401 let Some(index) = self.responses.iter().position(|existing| {
402 existing.status == response.status && existing.content_type == response.content_type
403 }) else {
404 self.responses.push(response);
405 return;
406 };
407
408 let mut existing = self.responses.remove(index);
409 response.headers.append(&mut existing.headers);
410 self.responses.push(response);
411 }
412}
413
414#[derive(Clone, Debug, Default, Deserialize, Serialize)]
415pub struct VendorExtensions {
416 #[serde(rename = "x-odata-filter", skip_serializing_if = "Option::is_none")]
417 pub x_odata_filter: Option<ODataPagination<BTreeMap<String, Vec<String>>>>,
418 #[serde(rename = "x-odata-orderby", skip_serializing_if = "Option::is_none")]
419 pub x_odata_orderby: Option<ODataPagination<Vec<String>>>,
420}
421
422#[derive(Clone, Debug, Default, Deserialize, Serialize)]
423pub struct ODataPagination<T> {
424 #[serde(rename = "allowedFields")]
425 pub allowed_fields: T,
426}
427
428/// Per-operation rate & concurrency limit specification
429#[derive(Clone, Debug, Default)]
430pub struct RateLimitSpec {
431 /// Target steady-state requests per second
432 pub rps: u32,
433 /// Maximum burst size (token bucket capacity)
434 pub burst: u32,
435 /// Maximum number of in-flight requests for this route
436 pub in_flight: u32,
437}
438
439#[derive(Clone, Debug, Deserialize, Serialize, Default)]
440#[serde(rename_all = "camelCase")]
441pub struct XPagination {
442 pub filter_fields: BTreeMap<String, Vec<String>>,
443 pub order_by: Vec<String>,
444}
445
446//
447pub trait OperationBuilderODataExt<S, H, R> {
448 /// Adds optional `$filter` query parameter to `OpenAPI`.
449 #[must_use]
450 fn with_odata_filter<T>(self) -> Self
451 where
452 T: toolkit_odata::filter::FilterField;
453
454 /// Adds optional `$select` query parameter to `OpenAPI`.
455 #[must_use]
456 fn with_odata_select(self) -> Self;
457
458 /// Adds optional `$orderby` query parameter to `OpenAPI`.
459 #[must_use]
460 fn with_odata_orderby<T>(self) -> Self
461 where
462 T: toolkit_odata::filter::FilterField;
463}
464
465impl<S, H, R, A, L> OperationBuilderODataExt<S, H, R> for OperationBuilder<H, R, S, A, L>
466where
467 H: HandlerSlot<S>,
468 A: AuthState,
469 L: LicenseState,
470{
471 fn with_odata_filter<T>(mut self) -> Self
472 where
473 T: toolkit_odata::filter::FilterField,
474 {
475 use std::fmt::Write as _;
476 use toolkit_odata::filter::FilterOp;
477
478 let mut filter = self
479 .spec
480 .vendor_extensions
481 .x_odata_filter
482 .unwrap_or_default();
483
484 let mut description = "OData v4 filter expression".to_owned();
485 for field in T::FIELDS {
486 let name = field.name().to_owned();
487 let kind = field.kind();
488
489 // Published straight from the parser's own table, so the contract
490 // cannot promise an operator the parser refuses, or hide one it
491 // accepts.
492 let ops: Vec<String> = [
493 FilterOp::Eq,
494 FilterOp::Ne,
495 FilterOp::Gt,
496 FilterOp::Ge,
497 FilterOp::Lt,
498 FilterOp::Le,
499 FilterOp::Contains,
500 FilterOp::StartsWith,
501 FilterOp::EndsWith,
502 FilterOp::In,
503 ]
504 .into_iter()
505 .filter(|op| kind.allows(*op))
506 .map(|op| op.to_string())
507 .collect();
508
509 _ = write!(description, "\n- {}: {}", name, ops.join("|"));
510 filter.allowed_fields.insert(name.clone(), ops);
511 }
512 self.spec.params.push(ParamSpec::scalar(
513 "$filter".to_owned(),
514 ParamLocation::Query,
515 false,
516 Some(description),
517 "string".to_owned(),
518 ));
519 self.spec.vendor_extensions.x_odata_filter = Some(filter);
520 self
521 }
522
523 fn with_odata_select(mut self) -> Self {
524 self.spec.params.push(ParamSpec::scalar(
525 "$select".to_owned(),
526 ParamLocation::Query,
527 false,
528 Some("OData v4 select expression".to_owned()),
529 "string".to_owned(),
530 ));
531 self
532 }
533
534 fn with_odata_orderby<T>(mut self) -> Self
535 where
536 T: toolkit_odata::filter::FilterField,
537 {
538 use std::fmt::Write as _;
539 let mut order_by = self
540 .spec
541 .vendor_extensions
542 .x_odata_orderby
543 .unwrap_or_default();
544 let mut description = "OData v4 orderby expression".to_owned();
545 for field in T::FIELDS {
546 let name = field.name().to_owned();
547
548 // Add sort options (asc/desc)
549 let asc = format!("{name} asc");
550 let desc = format!("{name} desc");
551
552 _ = write!(description, "\n- {asc}\n- {desc}");
553 if !order_by.allowed_fields.contains(&asc) {
554 order_by.allowed_fields.push(asc);
555 }
556 if !order_by.allowed_fields.contains(&desc) {
557 order_by.allowed_fields.push(desc);
558 }
559 }
560 self.spec.params.push(ParamSpec::scalar(
561 "$orderby".to_owned(),
562 ParamLocation::Query,
563 false,
564 Some(description),
565 "string".to_owned(),
566 ));
567 self.spec.vendor_extensions.x_odata_orderby = Some(order_by);
568 self
569 }
570}
571
572// Re-export from openapi_registry for backward compatibility
573pub use crate::api::openapi_registry::{OpenApiRegistry, ensure_schema};
574
575/// Type-safe operation builder with compile-time guarantees.
576///
577/// Generic parameters:
578/// - `H`: Handler state (Missing | Present)
579/// - `R`: Response state (Missing | Present)
580/// - `S`: Router state type (what you put into `Router::with_state(S)`).
581/// - `A`: Auth state (`AuthNotSet` | `AuthSet`)
582/// - `L`: License requirement state (`LicenseNotSet` | `LicenseSet`)
583#[must_use]
584pub struct OperationBuilder<H = Missing, R = Missing, S = (), A = AuthNotSet, L = LicenseNotSet>
585where
586 H: HandlerSlot<S>,
587 A: AuthState,
588 L: LicenseState,
589{
590 spec: OperationSpec,
591 method_router: <H as HandlerSlot<S>>::Slot,
592 _has_handler: PhantomData<H>,
593 _has_response: PhantomData<R>,
594 #[allow(clippy::type_complexity)]
595 _state: PhantomData<fn() -> S>, // Zero-sized marker for type-state pattern
596 _auth_state: PhantomData<A>,
597 _license_state: PhantomData<L>,
598}
599
600// -------------------------------------------------------------------------------------------------
601// Constructors — starts with both handler and response missing, auth not set
602// -------------------------------------------------------------------------------------------------
603impl<S> OperationBuilder<Missing, Missing, S, AuthNotSet> {
604 /// Create a new operation builder with an HTTP method and path
605 pub fn new(method: Method, path: impl Into<String>) -> Self {
606 let path_str = path.into();
607 let handler_id = format!(
608 "{}:{}",
609 method.as_str().to_lowercase(),
610 path_str.replace(['/', '{', '}'], "_")
611 );
612
613 Self {
614 spec: OperationSpec {
615 method,
616 path: path_str,
617 operation_id: None,
618 summary: None,
619 description: None,
620 tags: Vec::new(),
621 params: Vec::new(),
622 request_body: None,
623 responses: Vec::new(),
624 handler_id,
625 authenticated: false,
626 exposed: false,
627 rate_limit: None,
628 allowed_request_content_types: None,
629 vendor_extensions: VendorExtensions::default(),
630 license_requirement: None,
631 },
632 method_router: (), // no router in Missing state
633 _has_handler: PhantomData,
634 _has_response: PhantomData,
635 _state: PhantomData,
636 _auth_state: PhantomData,
637 _license_state: PhantomData,
638 }
639 }
640
641 /// Convenience constructor for GET requests
642 pub fn get(path: impl Into<String>) -> Self {
643 let path_str = path.into();
644 Self::new(Method::GET, normalize_to_axum_path(&path_str))
645 }
646
647 /// Convenience constructor for POST requests
648 pub fn post(path: impl Into<String>) -> Self {
649 let path_str = path.into();
650 Self::new(Method::POST, normalize_to_axum_path(&path_str))
651 }
652
653 /// Convenience constructor for PUT requests
654 pub fn put(path: impl Into<String>) -> Self {
655 let path_str = path.into();
656 Self::new(Method::PUT, normalize_to_axum_path(&path_str))
657 }
658
659 /// Convenience constructor for DELETE requests
660 pub fn delete(path: impl Into<String>) -> Self {
661 let path_str = path.into();
662 Self::new(Method::DELETE, normalize_to_axum_path(&path_str))
663 }
664
665 /// Convenience constructor for PATCH requests
666 pub fn patch(path: impl Into<String>) -> Self {
667 let path_str = path.into();
668 Self::new(Method::PATCH, normalize_to_axum_path(&path_str))
669 }
670}
671
672// -------------------------------------------------------------------------------------------------
673// Descriptive methods — available at any stage
674// -------------------------------------------------------------------------------------------------
675impl<H, R, S, A, L> OperationBuilder<H, R, S, A, L>
676where
677 H: HandlerSlot<S>,
678 A: AuthState,
679 L: LicenseState,
680{
681 /// Inspect the spec (primarily for tests)
682 pub fn spec(&self) -> &OperationSpec {
683 &self.spec
684 }
685
686 /// Set the operation ID
687 pub fn operation_id(mut self, id: impl Into<String>) -> Self {
688 self.spec.operation_id = Some(id.into());
689 self
690 }
691
692 /// Require per-route rate and concurrency limits.
693 /// Stores metadata for the gateway to enforce.
694 pub fn require_rate_limit(&mut self, rps: u32, burst: u32, in_flight: u32) -> &mut Self {
695 self.spec.rate_limit = Some(RateLimitSpec {
696 rps,
697 burst,
698 in_flight,
699 });
700 self
701 }
702
703 /// Set the operation summary
704 pub fn summary(mut self, text: impl Into<String>) -> Self {
705 self.spec.summary = Some(text.into());
706 self
707 }
708
709 /// Set the operation description
710 pub fn description(mut self, text: impl Into<String>) -> Self {
711 self.spec.description = Some(text.into());
712 self
713 }
714
715 /// Add a tag to the operation
716 pub fn tag(mut self, tag: impl Into<String>) -> Self {
717 self.spec.tags.push(tag.into());
718 self
719 }
720
721 /// Add a parameter to the operation
722 pub fn param(mut self, param: ParamSpec) -> Self {
723 self.spec.params.push(param);
724 self
725 }
726
727 /// Add a path parameter with type inference (defaults to string)
728 pub fn path_param(mut self, name: impl Into<String>, description: impl Into<String>) -> Self {
729 self.spec.params.push(ParamSpec::scalar(
730 name.into(),
731 ParamLocation::Path,
732 true,
733 Some(description.into()),
734 "string".to_owned(),
735 ));
736 self
737 }
738
739 /// Add a query parameter (defaults to string)
740 pub fn query_param(
741 mut self,
742 name: impl Into<String>,
743 required: bool,
744 description: impl Into<String>,
745 ) -> Self {
746 self.spec.params.push(ParamSpec::scalar(
747 name.into(),
748 ParamLocation::Query,
749 required,
750 Some(description.into()),
751 "string".to_owned(),
752 ));
753 self
754 }
755
756 /// Add a typed query parameter with explicit `OpenAPI` type
757 pub fn query_param_typed(
758 mut self,
759 name: impl Into<String>,
760 required: bool,
761 description: impl Into<String>,
762 param_type: impl Into<String>,
763 ) -> Self {
764 self.spec.params.push(ParamSpec::scalar(
765 name.into(),
766 ParamLocation::Query,
767 required,
768 Some(description.into()),
769 param_type.into(),
770 ));
771 self
772 }
773
774 /// Register every query parameter declared by a
775 /// `#[derive(toolkit_contract::QueryParams)]` struct.
776 ///
777 /// The generated REST routes use this so the spec and the wire format come
778 /// from one declaration. Fields render as scalars or, for `Vec` fields, as
779 /// `style: form, explode: true` arrays.
780 pub fn query_params_from<T: toolkit_contract::query::QueryParams>(mut self) -> Self {
781 for p in T::openapi_params() {
782 self.spec.params.push(ParamSpec {
783 name: p.name.to_owned(),
784 location: ParamLocation::Query,
785 required: p.required,
786 description: None,
787 param_type: p.openapi_type.to_owned(),
788 array: p.array,
789 });
790 }
791 self
792 }
793
794 /// Add a repeating query parameter — `?tag=a&tag=b`.
795 ///
796 /// `item_type` is the `OpenAPI` type of one element; the parameter renders
797 /// as an array with `style: form, explode: true`, which is the encoding the
798 /// generated REST client and its server extractor agree on for a `Vec<T>`
799 /// field.
800 pub fn query_param_array(
801 mut self,
802 name: impl Into<String>,
803 required: bool,
804 description: impl Into<String>,
805 item_type: impl Into<String>,
806 ) -> Self {
807 self.spec.params.push(ParamSpec {
808 name: name.into(),
809 location: ParamLocation::Query,
810 required,
811 description: Some(description.into()),
812 param_type: item_type.into(),
813 array: true,
814 });
815 self
816 }
817
818 /// Attach a JSON request body by *schema name* that you've already registered.
819 /// This variant sets a description (`Some(desc)`) and marks the body as **required**.
820 pub fn json_request_schema(
821 mut self,
822 schema_name: impl Into<String>,
823 desc: impl Into<String>,
824 ) -> Self {
825 self.spec.request_body = Some(RequestBodySpec {
826 content_type: "application/json",
827 description: Some(desc.into()),
828 schema: RequestBodySchema::Ref {
829 schema_name: schema_name.into(),
830 },
831 required: true,
832 });
833 self
834 }
835
836 /// Attach a JSON request body by *schema name* with **no** description (`None`).
837 /// Marks the body as **required**.
838 pub fn json_request_schema_no_desc(mut self, schema_name: impl Into<String>) -> Self {
839 self.spec.request_body = Some(RequestBodySpec {
840 content_type: "application/json",
841 description: None,
842 schema: RequestBodySchema::Ref {
843 schema_name: schema_name.into(),
844 },
845 required: true,
846 });
847 self
848 }
849
850 /// Attach a JSON request body and auto-register its schema using `utoipa`.
851 /// This variant sets a description (`Some(desc)`) and marks the body as **required**.
852 pub fn json_request<T>(
853 mut self,
854 registry: &dyn OpenApiRegistry,
855 desc: impl Into<String>,
856 ) -> Self
857 where
858 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::RequestApiDto + 'static,
859 {
860 let name = ensure_schema::<T>(registry);
861 self.spec.request_body = Some(RequestBodySpec {
862 content_type: "application/json",
863 description: Some(desc.into()),
864 schema: RequestBodySchema::Ref { schema_name: name },
865 required: true,
866 });
867 self
868 }
869
870 /// Attach a JSON request body (auto-register schema) with **no** description (`None`).
871 /// Marks the body as **required**.
872 pub fn json_request_no_desc<T>(mut self, registry: &dyn OpenApiRegistry) -> Self
873 where
874 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::RequestApiDto + 'static,
875 {
876 let name = ensure_schema::<T>(registry);
877 self.spec.request_body = Some(RequestBodySpec {
878 content_type: "application/json",
879 description: None,
880 schema: RequestBodySchema::Ref { schema_name: name },
881 required: true,
882 });
883 self
884 }
885
886 /// Make the previously attached request body **optional** (if any).
887 pub fn request_optional(mut self) -> Self {
888 if let Some(rb) = &mut self.spec.request_body {
889 rb.required = false;
890 }
891 self
892 }
893
894 /// Configure a multipart/form-data file upload request.
895 ///
896 /// This is a convenience helper for file upload endpoints that:
897 /// - Sets the request body content type to "multipart/form-data"
898 /// - Sets a description for the request body
899 /// - Configures an inline object schema with a binary file field
900 /// - Restricts allowed Content-Type to only "multipart/form-data"
901 ///
902 /// The file field will be documented in `OpenAPI` as a binary string with the
903 /// given field name. This generates the correct `OpenAPI` schema for UI tools
904 /// like Stoplight to display a file upload control.
905 ///
906 /// # Arguments
907 /// * `field_name` - Name of the multipart form field (e.g., "file")
908 /// * `description` - Optional description for the request body
909 ///
910 /// # Example
911 /// ```rust
912 /// # use axum::Router;
913 /// # use http::StatusCode;
914 /// # use toolkit::api::{
915 /// # openapi_registry::OpenApiRegistryImpl,
916 /// # operation_builder::OperationBuilder,
917 /// # };
918 /// # async fn upload_handler() -> &'static str { "uploaded" }
919 /// # let registry = OpenApiRegistryImpl::new();
920 /// # let router: Router<()> = Router::new();
921 /// let router = OperationBuilder::post("/files/v1/upload")
922 /// .operation_id("upload_file")
923 /// .summary("Upload a file")
924 /// .multipart_file_request("file", Some("File to upload"))
925 /// .anonymous()
926 /// .handler(upload_handler)
927 /// .json_response(StatusCode::OK, "Upload successful")
928 /// .register(router, ®istry);
929 /// # let _ = router;
930 /// ```
931 pub fn multipart_file_request(mut self, field_name: &str, description: Option<&str>) -> Self {
932 // Set request body with multipart/form-data content type
933 self.spec.request_body = Some(RequestBodySpec {
934 content_type: "multipart/form-data",
935 description: description
936 .map(|s| format!("{s} (expects field '{field_name}' with file data)")),
937 schema: RequestBodySchema::MultipartFile {
938 field_name: field_name.to_owned(),
939 },
940 required: true,
941 });
942
943 // Also configure MIME type validation
944 self.spec.allowed_request_content_types = Some(vec!["multipart/form-data"]);
945
946 self
947 }
948
949 /// Configure the request body as raw binary (application/octet-stream).
950 ///
951 /// This is intended for endpoints that accept the entire request body
952 /// as a file or arbitrary bytes, without multipart form encoding.
953 ///
954 /// The `OpenAPI` schema will be:
955 /// ```yaml
956 /// requestBody:
957 /// required: true
958 /// content:
959 /// application/octet-stream:
960 /// schema:
961 /// type: string
962 /// format: binary
963 /// ```
964 ///
965 /// Tools like Stoplight will render this as a single file upload control
966 /// for the entire body.
967 ///
968 /// # Arguments
969 /// * `description` - Optional description for the request body
970 ///
971 /// # Example
972 /// ```rust
973 /// # use axum::Router;
974 /// # use http::StatusCode;
975 /// # use toolkit::api::{
976 /// # openapi_registry::OpenApiRegistryImpl,
977 /// # operation_builder::OperationBuilder,
978 /// # };
979 /// # async fn upload_handler() -> &'static str { "uploaded" }
980 /// # let registry = OpenApiRegistryImpl::new();
981 /// # let router: Router<()> = Router::new();
982 /// let router = OperationBuilder::post("/files/v1/upload")
983 /// .operation_id("upload_file")
984 /// .summary("Upload a file")
985 /// .octet_stream_request(Some("Raw file bytes to parse"))
986 /// .anonymous()
987 /// .handler(upload_handler)
988 /// .json_response(StatusCode::OK, "Upload successful")
989 /// .register(router, ®istry);
990 /// # let _ = router;
991 /// ```
992 pub fn octet_stream_request(mut self, description: Option<&str>) -> Self {
993 self.spec.request_body = Some(RequestBodySpec {
994 content_type: "application/octet-stream",
995 description: description.map(str::to_owned),
996 schema: RequestBodySchema::Binary,
997 required: true,
998 });
999
1000 // Also configure MIME type validation
1001 self.spec.allowed_request_content_types = Some(vec!["application/octet-stream"]);
1002
1003 self
1004 }
1005
1006 /// Configure allowed request MIME types for this operation.
1007 ///
1008 /// This attaches a whitelist of allowed Content-Type values (without parameters),
1009 /// which will be enforced by gateway middleware. If a request arrives with a
1010 /// Content-Type that is not in this list, gateway will return HTTP 415.
1011 ///
1012 /// This is independent of the request body schema - it only configures gateway
1013 /// validation and does not affect `OpenAPI` request body specifications.
1014 ///
1015 /// # Example
1016 /// ```rust
1017 /// # use axum::Router;
1018 /// # use http::StatusCode;
1019 /// # use toolkit::api::{
1020 /// # openapi_registry::OpenApiRegistryImpl,
1021 /// # operation_builder::OperationBuilder,
1022 /// # };
1023 /// # async fn upload_handler() -> &'static str { "uploaded" }
1024 /// # let registry = OpenApiRegistryImpl::new();
1025 /// # let router: Router<()> = Router::new();
1026 /// let router = OperationBuilder::post("/files/v1/upload")
1027 /// .operation_id("upload_file")
1028 /// .allow_content_types(&["multipart/form-data", "application/pdf"])
1029 /// .anonymous()
1030 /// .handler(upload_handler)
1031 /// .json_response(StatusCode::OK, "Upload successful")
1032 /// .register(router, ®istry);
1033 /// # let _ = router;
1034 /// ```
1035 pub fn allow_content_types(mut self, types: &[&'static str]) -> Self {
1036 self.spec.allowed_request_content_types = Some(types.to_vec());
1037 self
1038 }
1039
1040 /// Mark this route as **publicly visible** — registered in the gateway for
1041 /// external access (the *visibility* axis).
1042 ///
1043 /// This is independent of authentication (`.authenticated()` /
1044 /// `.anonymous()`): an exposed route may still require a JWT. Routes are
1045 /// **internal by default** (not registered in the gateway). Available at any
1046 /// stage of the builder.
1047 pub fn exposed(mut self) -> Self {
1048 self.spec.exposed = true;
1049 self
1050 }
1051}
1052
1053/// License requirement setting — transitions `LicenseNotSet` -> `LicenseSet`
1054impl<H, R, S> OperationBuilder<H, R, S, AuthSet, LicenseNotSet>
1055where
1056 H: HandlerSlot<S>,
1057{
1058 /// Set (or explicitly clear) the license feature requirement for this operation.
1059 ///
1060 /// This method is only available after the auth requirement has been decided
1061 /// (i.e. after calling `authenticated()`).
1062 ///
1063 /// **Mandatory for authenticated endpoints:** operations configured with `authenticated()`
1064 /// must call `require_license_features(...)` before `register()`, because `register()` is only
1065 /// available once the license requirement state has transitioned to `LicenseSet`.
1066 ///
1067 /// **Not available for public endpoints:** public routes cannot (and do not need to) call this method.
1068 ///
1069 /// Pass an empty iterator (e.g. `[]`) to explicitly declare that no license feature is required.
1070 pub fn require_license_features<F>(
1071 mut self,
1072 licenses: impl IntoIterator<Item = F>,
1073 ) -> OperationBuilder<H, R, S, AuthSet, LicenseSet>
1074 where
1075 F: LicenseFeature,
1076 {
1077 let license_names: Vec<String> = licenses
1078 .into_iter()
1079 .map(|l| l.as_ref().to_owned())
1080 .collect();
1081
1082 self.spec.license_requirement =
1083 (!license_names.is_empty()).then_some(LicenseReqSpec { license_names });
1084
1085 OperationBuilder {
1086 spec: self.spec,
1087 method_router: self.method_router,
1088 _has_handler: self._has_handler,
1089 _has_response: self._has_response,
1090 _state: self._state,
1091 _auth_state: self._auth_state,
1092 _license_state: PhantomData,
1093 }
1094 }
1095
1096 /// Explicitly declare that this operation does not require any license.
1097 ///
1098 /// Use this for system/infrastructure endpoints that need authentication
1099 /// but are not gated behind application-level license features.
1100 ///
1101 /// This transitions from `LicenseNotSet` to `LicenseSet` without
1102 /// attaching any license requirement.
1103 pub fn no_license_required(self) -> OperationBuilder<H, R, S, AuthSet, LicenseSet> {
1104 OperationBuilder {
1105 spec: self.spec,
1106 method_router: self.method_router,
1107 _has_handler: self._has_handler,
1108 _has_response: self._has_response,
1109 _state: self._state,
1110 _auth_state: self._auth_state,
1111 _license_state: PhantomData,
1112 }
1113 }
1114}
1115
1116// -------------------------------------------------------------------------------------------------
1117// Auth requirement setting — transitions AuthNotSet -> AuthSet
1118// -------------------------------------------------------------------------------------------------
1119impl<H, R, S, L> OperationBuilder<H, R, S, AuthNotSet, L>
1120where
1121 H: HandlerSlot<S>,
1122 L: LicenseState,
1123{
1124 /// Mark this route as requiring authentication.
1125 ///
1126 /// This is a binary marker — the route requires a valid bearer token.
1127 /// Scope enforcement (which scopes are needed) is configured at the
1128 /// gateway level, not per-route.
1129 ///
1130 /// This method transitions from `AuthNotSet` to `AuthSet` state.
1131 ///
1132 /// # Example
1133 /// ```rust
1134 /// # use toolkit::api::operation_builder::{
1135 /// # OperationBuilder, LicenseFeature, CORE_GLOBAL_BASE_LICENSE_FEATURE,
1136 /// # };
1137 /// # use axum::{extract::Json, Router };
1138 /// # use serde::{Serialize};
1139 /// #
1140 /// # #[derive(Serialize)]
1141 /// # pub struct User;
1142 /// #
1143 /// enum License {
1144 /// Base,
1145 /// }
1146 ///
1147 /// impl AsRef<str> for License {
1148 /// fn as_ref(&self) -> &str {
1149 /// match self {
1150 /// License::Base => CORE_GLOBAL_BASE_LICENSE_FEATURE,
1151 /// }
1152 /// }
1153 /// }
1154 ///
1155 /// impl LicenseFeature for License {}
1156 ///
1157 /// #
1158 /// # fn register_rest(
1159 /// # router: axum::Router,
1160 /// # api: &dyn toolkit::api::OpenApiRegistry,
1161 /// # ) -> anyhow::Result<axum::Router> {
1162 /// let router = OperationBuilder::get("/users-info/v1/users")
1163 /// .authenticated()
1164 /// .require_license_features::<License>([])
1165 /// .handler(list_users_handler)
1166 /// .json_response(axum::http::StatusCode::OK, "List of users")
1167 /// .register(router, api);
1168 /// # Ok(router)
1169 /// # }
1170 ///
1171 /// # async fn list_users_handler() -> Json<Vec<User>> {
1172 /// # unimplemented!()
1173 /// # }
1174 /// ```
1175 pub fn authenticated(mut self) -> OperationBuilder<H, R, S, AuthSet, L> {
1176 self.spec.authenticated = true;
1177 OperationBuilder {
1178 spec: self.spec,
1179 method_router: self.method_router,
1180 _has_handler: self._has_handler,
1181 _has_response: self._has_response,
1182 _state: self._state,
1183 _auth_state: PhantomData,
1184 _license_state: self._license_state,
1185 }
1186 }
1187
1188 /// Mark this route as **anonymous** — no authentication required (the *auth*
1189 /// axis).
1190 ///
1191 /// A missing `Authorization: Bearer` header is allowed; a present bearer is
1192 /// still always re-validated. This explicitly opts out of the
1193 /// `require_auth_by_default` setting and maps to the `AnonymousRoute` marker
1194 /// in the `OoP` per-gear middleware. It is independent of visibility — use
1195 /// [`exposed`](Self::exposed) to also register the route in the gateway.
1196 /// This method transitions from `AuthNotSet` to `AuthSet` state.
1197 ///
1198 /// # Example
1199 /// ```rust
1200 /// # use axum::Router;
1201 /// # use http::StatusCode;
1202 /// # use toolkit::api::{
1203 /// # openapi_registry::OpenApiRegistryImpl,
1204 /// # operation_builder::OperationBuilder,
1205 /// # };
1206 /// # async fn health_check() -> &'static str { "OK" }
1207 /// # let registry = OpenApiRegistryImpl::new();
1208 /// # let router: Router<()> = Router::new();
1209 /// let router = OperationBuilder::get("/users-info/v1/health")
1210 /// .anonymous()
1211 /// .handler(health_check)
1212 /// .json_response(StatusCode::OK, "OK")
1213 /// .register(router, ®istry);
1214 /// # let _ = router;
1215 /// ```
1216 pub fn anonymous(mut self) -> OperationBuilder<H, R, S, AuthSet, LicenseSet> {
1217 self.spec.authenticated = false;
1218 OperationBuilder {
1219 spec: self.spec,
1220 method_router: self.method_router,
1221 _has_handler: self._has_handler,
1222 _has_response: self._has_response,
1223 _state: self._state,
1224 _auth_state: PhantomData,
1225 _license_state: PhantomData,
1226 }
1227 }
1228
1229 /// Deprecated alias for the old single-axis `.public()`.
1230 ///
1231 /// The old `.public()` meant both **anonymous** (no auth) *and* **edge
1232 /// visible**. Those are now separate axes: [`anonymous`](Self::anonymous)
1233 /// (auth) and [`exposed`](Self::exposed) (visibility). This shim maps to
1234 /// `.anonymous().exposed()` so out-of-tree gears keep compiling for one
1235 /// release; a bare `.anonymous()` (the naive mechanical replacement) would
1236 /// silently drop the route from the edge, which this warning surfaces at
1237 /// compile time instead.
1238 #[deprecated(
1239 since = "0.6.21",
1240 note = "`.public()` split into two axes; use `.anonymous().exposed()` \
1241 (this alias forwards to exactly that)"
1242 )]
1243 pub fn public(self) -> OperationBuilder<H, R, S, AuthSet, LicenseSet> {
1244 self.anonymous().exposed()
1245 }
1246}
1247
1248// -------------------------------------------------------------------------------------------------
1249// Handler setting — transitions Missing -> Present for handler
1250// -------------------------------------------------------------------------------------------------
1251impl<R, S, A, L> OperationBuilder<Missing, R, S, A, L>
1252where
1253 S: Clone + Send + Sync + 'static,
1254 A: AuthState,
1255 L: LicenseState,
1256{
1257 /// Set the handler for this operation (function handlers are recommended).
1258 ///
1259 /// This transitions the builder from `Missing` to `Present` handler state.
1260 pub fn handler<F, T>(self, h: F) -> OperationBuilder<Present, R, S, A, L>
1261 where
1262 F: Handler<T, S> + Clone + Send + 'static,
1263 T: 'static,
1264 {
1265 let method_router = match self.spec.method {
1266 Method::GET => axum::routing::get(h),
1267 Method::POST => axum::routing::post(h),
1268 Method::PUT => axum::routing::put(h),
1269 Method::DELETE => axum::routing::delete(h),
1270 Method::PATCH => axum::routing::patch(h),
1271 _ => axum::routing::any(|| async { axum::http::StatusCode::METHOD_NOT_ALLOWED }),
1272 };
1273
1274 OperationBuilder {
1275 spec: self.spec,
1276 method_router, // concrete MethodRouter<S> in Present state
1277 _has_handler: PhantomData::<Present>,
1278 _has_response: self._has_response,
1279 _state: self._state,
1280 _auth_state: self._auth_state,
1281 _license_state: self._license_state,
1282 }
1283 }
1284
1285 /// Alternative path: provide a pre-composed `MethodRouter<S>` yourself
1286 /// (useful to attach per-route middleware/layers).
1287 pub fn method_router(self, mr: MethodRouter<S>) -> OperationBuilder<Present, R, S, A, L> {
1288 OperationBuilder {
1289 spec: self.spec,
1290 method_router: mr, // concrete MethodRouter<S> in Present state
1291 _has_handler: PhantomData::<Present>,
1292 _has_response: self._has_response,
1293 _state: self._state,
1294 _auth_state: self._auth_state,
1295 _license_state: self._license_state,
1296 }
1297 }
1298}
1299
1300// -------------------------------------------------------------------------------------------------
1301// Response setting — transitions Missing -> Present for response (first response)
1302// -------------------------------------------------------------------------------------------------
1303impl<H, S, A, L> OperationBuilder<H, Missing, S, A, L>
1304where
1305 H: HandlerSlot<S>,
1306 A: AuthState,
1307 L: LicenseState,
1308{
1309 /// Add a raw response spec (transitions from Missing to Present).
1310 pub fn response(mut self, resp: ResponseSpec) -> OperationBuilder<H, Present, S, A, L> {
1311 self.spec.responses.push(resp);
1312 OperationBuilder {
1313 spec: self.spec,
1314 method_router: self.method_router,
1315 _has_handler: self._has_handler,
1316 _has_response: PhantomData::<Present>,
1317 _state: self._state,
1318 _auth_state: self._auth_state,
1319 _license_state: self._license_state,
1320 }
1321 }
1322
1323 /// Add a JSON response (transitions from Missing to Present).
1324 pub fn json_response(
1325 mut self,
1326 status: http::StatusCode,
1327 description: impl Into<String>,
1328 ) -> OperationBuilder<H, Present, S, A, L> {
1329 self.spec.responses.push(ResponseSpec {
1330 status: status.as_u16(),
1331 content_type: "application/json",
1332 description: description.into(),
1333 schema: None,
1334 headers: Vec::new(),
1335 });
1336 OperationBuilder {
1337 spec: self.spec,
1338 method_router: self.method_router,
1339 _has_handler: self._has_handler,
1340 _has_response: PhantomData::<Present>,
1341 _state: self._state,
1342 _auth_state: self._auth_state,
1343 _license_state: self._license_state,
1344 }
1345 }
1346
1347 /// Add a body-less response (e.g. `204 No Content`) — transitions from
1348 /// Missing to Present.
1349 ///
1350 /// `OpenAPI` consumers and code-generators treat a `204` response with a
1351 /// `content` block as advertising a body, which is incorrect. Use this
1352 /// helper for any handler that intentionally returns no payload (typical
1353 /// for `DELETE` / `PUT` semantics).
1354 pub fn no_content_response(
1355 mut self,
1356 status: http::StatusCode,
1357 description: impl Into<String>,
1358 ) -> OperationBuilder<H, Present, S, A, L> {
1359 self.spec.responses.push(ResponseSpec {
1360 status: status.as_u16(),
1361 content_type: "",
1362 description: description.into(),
1363 schema: None,
1364 headers: Vec::new(),
1365 });
1366 OperationBuilder {
1367 spec: self.spec,
1368 method_router: self.method_router,
1369 _has_handler: self._has_handler,
1370 _has_response: PhantomData::<Present>,
1371 _state: self._state,
1372 _auth_state: self._auth_state,
1373 _license_state: self._license_state,
1374 }
1375 }
1376
1377 /// Add a JSON response with a registered schema (transitions from Missing to Present).
1378 pub fn json_response_with_schema<T>(
1379 mut self,
1380 registry: &dyn OpenApiRegistry,
1381 status: http::StatusCode,
1382 description: impl Into<String>,
1383 ) -> OperationBuilder<H, Present, S, A, L>
1384 where
1385 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
1386 {
1387 let name = ensure_schema::<T>(registry);
1388 self.spec.responses.push(ResponseSpec {
1389 status: status.as_u16(),
1390 content_type: "application/json",
1391 description: description.into(),
1392 schema: Some(ResponseSchema::Ref { schema_name: name }),
1393 headers: Vec::new(),
1394 });
1395 OperationBuilder {
1396 spec: self.spec,
1397 method_router: self.method_router,
1398 _has_handler: self._has_handler,
1399 _has_response: PhantomData::<Present>,
1400 _state: self._state,
1401 _auth_state: self._auth_state,
1402 _license_state: self._license_state,
1403 }
1404 }
1405
1406 /// Add a JSON response whose body is a **top-level array** of `T`
1407 /// (transitions from Missing to Present).
1408 ///
1409 /// `T` is the *item* type — pass `GearDto`, not `Vec<GearDto>`. Registers
1410 /// `T` as a named component and emits an inline
1411 /// `{type: array, items: {$ref: T}}` schema for the response body.
1412 ///
1413 /// Never pass `Vec<T>` to [`Self::json_response_with_schema`]: utoipa's
1414 /// default `ToSchema::name()` strips generic arguments, so every `Vec<_>`
1415 /// registers under the single component name `Vec` and two such responses
1416 /// collide fatally in `OpenApiRegistryImpl::ensure_schema_raw`.
1417 pub fn json_array_response_with_schema<T>(
1418 mut self,
1419 registry: &dyn OpenApiRegistry,
1420 status: http::StatusCode,
1421 description: impl Into<String>,
1422 ) -> OperationBuilder<H, Present, S, A, L>
1423 where
1424 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
1425 {
1426 let items_schema_name = ensure_schema::<T>(registry);
1427 self.spec.responses.push(ResponseSpec {
1428 status: status.as_u16(),
1429 content_type: "application/json",
1430 description: description.into(),
1431 schema: Some(ResponseSchema::Array { items_schema_name }),
1432 headers: Vec::new(),
1433 });
1434 OperationBuilder {
1435 spec: self.spec,
1436 method_router: self.method_router,
1437 _has_handler: self._has_handler,
1438 _has_response: PhantomData::<Present>,
1439 _state: self._state,
1440 _auth_state: self._auth_state,
1441 _license_state: self._license_state,
1442 }
1443 }
1444
1445 /// Add a text response with a custom content type (transitions from Missing to Present).
1446 ///
1447 /// # Arguments
1448 /// * `status` - HTTP status code
1449 /// * `description` - Description of the response
1450 /// * `content_type` - **Pure media type without parameters** (e.g., `"text/plain"`, `"text/markdown"`)
1451 ///
1452 /// # Important
1453 /// The `content_type` must be a pure media type **without parameters** like `; charset=utf-8`.
1454 /// `OpenAPI` media type keys cannot include parameters. Use `"text/markdown"` instead of
1455 /// `"text/markdown; charset=utf-8"`. Actual HTTP response headers in handlers should still
1456 /// include the charset parameter.
1457 pub fn text_response(
1458 mut self,
1459 status: http::StatusCode,
1460 description: impl Into<String>,
1461 content_type: &'static str,
1462 ) -> OperationBuilder<H, Present, S, A, L> {
1463 self.spec.responses.push(ResponseSpec {
1464 status: status.as_u16(),
1465 content_type,
1466 description: description.into(),
1467 schema: None,
1468 headers: Vec::new(),
1469 });
1470 OperationBuilder {
1471 spec: self.spec,
1472 method_router: self.method_router,
1473 _has_handler: self._has_handler,
1474 _has_response: PhantomData::<Present>,
1475 _state: self._state,
1476 _auth_state: self._auth_state,
1477 _license_state: self._license_state,
1478 }
1479 }
1480
1481 /// Add an HTML response (transitions from Missing to Present).
1482 pub fn html_response(
1483 mut self,
1484 status: http::StatusCode,
1485 description: impl Into<String>,
1486 ) -> OperationBuilder<H, Present, S, A, L> {
1487 self.spec.responses.push(ResponseSpec {
1488 status: status.as_u16(),
1489 content_type: "text/html",
1490 description: description.into(),
1491 schema: None,
1492 headers: Vec::new(),
1493 });
1494 OperationBuilder {
1495 spec: self.spec,
1496 method_router: self.method_router,
1497 _has_handler: self._has_handler,
1498 _has_response: PhantomData::<Present>,
1499 _state: self._state,
1500 _auth_state: self._auth_state,
1501 _license_state: self._license_state,
1502 }
1503 }
1504
1505 /// Add an RFC 9457 `application/problem+json` response (transitions from Missing to Present).
1506 pub fn problem_response(
1507 mut self,
1508 registry: &dyn OpenApiRegistry,
1509 status: http::StatusCode,
1510 description: impl Into<String>,
1511 ) -> OperationBuilder<H, Present, S, A, L> {
1512 // Canonical Problem schema (RFC 9457 + GTS-typed). Component name "Problem".
1513 let problem_name = ensure_schema::<toolkit_canonical_errors::Problem>(registry);
1514 self.spec.responses.push(ResponseSpec {
1515 status: status.as_u16(),
1516 content_type: problem::APPLICATION_PROBLEM_JSON,
1517 description: description.into(),
1518 schema: Some(ResponseSchema::Ref {
1519 schema_name: problem_name,
1520 }),
1521 headers: Vec::new(),
1522 });
1523 OperationBuilder {
1524 spec: self.spec,
1525 method_router: self.method_router,
1526 _has_handler: self._has_handler,
1527 _has_response: PhantomData::<Present>,
1528 _state: self._state,
1529 _auth_state: self._auth_state,
1530 _license_state: self._license_state,
1531 }
1532 }
1533
1534 /// First response: SSE stream of JSON events (`text/event-stream`).
1535 pub fn sse_json<T>(
1536 mut self,
1537 openapi: &dyn OpenApiRegistry,
1538 description: impl Into<String>,
1539 ) -> OperationBuilder<H, Present, S, A, L>
1540 where
1541 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
1542 {
1543 let name = ensure_schema::<T>(openapi);
1544 self.spec.responses.push(ResponseSpec {
1545 status: http::StatusCode::OK.as_u16(),
1546 content_type: "text/event-stream",
1547 description: description.into(),
1548 schema: Some(ResponseSchema::Ref { schema_name: name }),
1549 headers: Vec::new(),
1550 });
1551 OperationBuilder {
1552 spec: self.spec,
1553 method_router: self.method_router,
1554 _has_handler: self._has_handler,
1555 _has_response: PhantomData::<Present>,
1556 _state: self._state,
1557 _auth_state: self._auth_state,
1558 _license_state: self._license_state,
1559 }
1560 }
1561
1562 /// First response: `multipart/mixed` stream of JSON items, one item per
1563 /// body part — the server-to-server counterpart to [`Self::sse_json`].
1564 ///
1565 /// `T` is the *item* type, exactly as for SSE: the media-type key in the
1566 /// spec is the bare `multipart/mixed`, with no `boundary=` parameter,
1567 /// because the boundary is generated per response at runtime by
1568 /// [`crate::http::multipart::MultipartJsonStream`] and is not a property of
1569 /// the operation.
1570 pub fn multipart_json<T>(
1571 mut self,
1572 openapi: &dyn OpenApiRegistry,
1573 description: impl Into<String>,
1574 ) -> OperationBuilder<H, Present, S, A, L>
1575 where
1576 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
1577 {
1578 let name = ensure_schema::<T>(openapi);
1579 self.spec.responses.push(ResponseSpec {
1580 status: http::StatusCode::OK.as_u16(),
1581 content_type: "multipart/mixed",
1582 description: description.into(),
1583 schema: Some(ResponseSchema::Ref { schema_name: name }),
1584 headers: Vec::new(),
1585 });
1586 OperationBuilder {
1587 spec: self.spec,
1588 method_router: self.method_router,
1589 _has_handler: self._has_handler,
1590 _has_response: PhantomData::<Present>,
1591 _state: self._state,
1592 _auth_state: self._auth_state,
1593 _license_state: self._license_state,
1594 }
1595 }
1596}
1597
1598// -------------------------------------------------------------------------------------------------
1599// Additional responses — for Present response state (additional responses)
1600// -------------------------------------------------------------------------------------------------
1601impl<H, S, A, L> OperationBuilder<H, Present, S, A, L>
1602where
1603 H: HandlerSlot<S>,
1604 A: AuthState,
1605 L: LicenseState,
1606{
1607 /// Declare a header on the most recently declared response.
1608 ///
1609 /// Call this immediately after the response declaration it describes.
1610 /// Consecutive calls attach multiple headers to that same response.
1611 ///
1612 /// # Panics
1613 /// Panics when the response status already has a header with the same
1614 /// case-insensitive name.
1615 pub fn response_header(mut self, header: ResponseHeaderSpec) -> Self {
1616 let Some(response) = self.spec.responses.last() else {
1617 unreachable!("Present response state guarantees a response");
1618 };
1619 let status = response.status;
1620 assert!(
1621 !self.spec.responses.iter().any(|response| {
1622 response.status == status
1623 && response
1624 .headers
1625 .iter()
1626 .any(|existing| existing.name.eq_ignore_ascii_case(&header.name))
1627 }),
1628 "response {status} already declares header '{}'",
1629 header.name
1630 );
1631 let Some(response) = self.spec.responses.last_mut() else {
1632 unreachable!("Present response state guarantees a response");
1633 };
1634 response.headers.push(header);
1635 self
1636 }
1637
1638 /// Add a JSON response (additional).
1639 pub fn json_response(
1640 mut self,
1641 status: http::StatusCode,
1642 description: impl Into<String>,
1643 ) -> Self {
1644 self.spec.upsert_response(ResponseSpec {
1645 status: status.as_u16(),
1646 content_type: "application/json",
1647 description: description.into(),
1648 schema: None,
1649 headers: Vec::new(),
1650 });
1651 self
1652 }
1653
1654 /// Add a body-less response (e.g. `204 No Content`) — additional variant.
1655 pub fn no_content_response(
1656 mut self,
1657 status: http::StatusCode,
1658 description: impl Into<String>,
1659 ) -> Self {
1660 self.spec.upsert_response(ResponseSpec {
1661 status: status.as_u16(),
1662 content_type: "",
1663 description: description.into(),
1664 schema: None,
1665 headers: Vec::new(),
1666 });
1667 self
1668 }
1669
1670 /// Add a JSON response with a registered schema (additional).
1671 pub fn json_response_with_schema<T>(
1672 mut self,
1673 registry: &dyn OpenApiRegistry,
1674 status: http::StatusCode,
1675 description: impl Into<String>,
1676 ) -> Self
1677 where
1678 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
1679 {
1680 let name = ensure_schema::<T>(registry);
1681 self.spec.upsert_response(ResponseSpec {
1682 status: status.as_u16(),
1683 content_type: "application/json",
1684 description: description.into(),
1685 schema: Some(ResponseSchema::Ref { schema_name: name }),
1686 headers: Vec::new(),
1687 });
1688 self
1689 }
1690
1691 /// Add a JSON response whose body is a **top-level array** of `T` (additional).
1692 ///
1693 /// `T` is the *item* type — pass `GearDto`, not `Vec<GearDto>`. See
1694 /// [`OperationBuilder::json_array_response_with_schema`] on the
1695 /// `Missing`-response builder for why arrays are emitted inline.
1696 pub fn json_array_response_with_schema<T>(
1697 mut self,
1698 registry: &dyn OpenApiRegistry,
1699 status: http::StatusCode,
1700 description: impl Into<String>,
1701 ) -> Self
1702 where
1703 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
1704 {
1705 let items_schema_name = ensure_schema::<T>(registry);
1706 self.spec.upsert_response(ResponseSpec {
1707 status: status.as_u16(),
1708 content_type: "application/json",
1709 description: description.into(),
1710 schema: Some(ResponseSchema::Array { items_schema_name }),
1711 headers: Vec::new(),
1712 });
1713 self
1714 }
1715
1716 /// Add a text response with a custom content type (additional).
1717 ///
1718 /// # Arguments
1719 /// * `status` - HTTP status code
1720 /// * `description` - Description of the response
1721 /// * `content_type` - **Pure media type without parameters** (e.g., `"text/plain"`, `"text/markdown"`)
1722 ///
1723 /// # Important
1724 /// The `content_type` must be a pure media type **without parameters** like `; charset=utf-8`.
1725 /// `OpenAPI` media type keys cannot include parameters. Use `"text/markdown"` instead of
1726 /// `"text/markdown; charset=utf-8"`. Actual HTTP response headers in handlers should still
1727 /// include the charset parameter.
1728 pub fn text_response(
1729 mut self,
1730 status: http::StatusCode,
1731 description: impl Into<String>,
1732 content_type: &'static str,
1733 ) -> Self {
1734 self.spec.upsert_response(ResponseSpec {
1735 status: status.as_u16(),
1736 content_type,
1737 description: description.into(),
1738 schema: None,
1739 headers: Vec::new(),
1740 });
1741 self
1742 }
1743
1744 /// Add an HTML response (additional).
1745 pub fn html_response(
1746 mut self,
1747 status: http::StatusCode,
1748 description: impl Into<String>,
1749 ) -> Self {
1750 self.spec.upsert_response(ResponseSpec {
1751 status: status.as_u16(),
1752 content_type: "text/html",
1753 description: description.into(),
1754 schema: None,
1755 headers: Vec::new(),
1756 });
1757 self
1758 }
1759
1760 /// Add an additional RFC 9457 `application/problem+json` response.
1761 pub fn problem_response(
1762 mut self,
1763 registry: &dyn OpenApiRegistry,
1764 status: http::StatusCode,
1765 description: impl Into<String>,
1766 ) -> Self {
1767 // Canonical Problem schema (RFC 9457 + GTS-typed). Component name "Problem".
1768 let problem_name = ensure_schema::<toolkit_canonical_errors::Problem>(registry);
1769 self.spec.upsert_response(ResponseSpec {
1770 status: status.as_u16(),
1771 content_type: problem::APPLICATION_PROBLEM_JSON,
1772 description: description.into(),
1773 schema: Some(ResponseSchema::Ref {
1774 schema_name: problem_name,
1775 }),
1776 headers: Vec::new(),
1777 });
1778 self
1779 }
1780
1781 /// Additional SSE response (if the operation already has a response).
1782 pub fn sse_json<T>(
1783 mut self,
1784 openapi: &dyn OpenApiRegistry,
1785 description: impl Into<String>,
1786 ) -> Self
1787 where
1788 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
1789 {
1790 let name = ensure_schema::<T>(openapi);
1791 self.spec.upsert_response(ResponseSpec {
1792 status: http::StatusCode::OK.as_u16(),
1793 content_type: "text/event-stream",
1794 description: description.into(),
1795 schema: Some(ResponseSchema::Ref { schema_name: name }),
1796 headers: Vec::new(),
1797 });
1798 self
1799 }
1800
1801 /// Additional `multipart/mixed` response (if the operation already has
1802 /// one). See [`OperationBuilder::multipart_json`] on the first-response
1803 /// builder for what `T` and the media-type key mean.
1804 pub fn multipart_json<T>(
1805 mut self,
1806 openapi: &dyn OpenApiRegistry,
1807 description: impl Into<String>,
1808 ) -> Self
1809 where
1810 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
1811 {
1812 let name = ensure_schema::<T>(openapi);
1813 self.spec.upsert_response(ResponseSpec {
1814 status: http::StatusCode::OK.as_u16(),
1815 content_type: "multipart/mixed",
1816 description: description.into(),
1817 schema: Some(ResponseSchema::Ref { schema_name: name }),
1818 headers: Vec::new(),
1819 });
1820 self
1821 }
1822
1823 /// Add standard error responses (400, 401, 403, 404, 409, 422, 429, 500).
1824 ///
1825 /// All responses reference the shared Problem schema (RFC 9457) for consistent
1826 /// error handling across your API. This is the recommended way to declare
1827 /// common error responses without repeating boilerplate.
1828 ///
1829 /// # Example
1830 ///
1831 /// ```rust
1832 /// # use axum::Router;
1833 /// # use http::StatusCode;
1834 /// # use toolkit::api::{
1835 /// # openapi_registry::OpenApiRegistryImpl,
1836 /// # operation_builder::OperationBuilder,
1837 /// # };
1838 /// # async fn list_users() -> &'static str { "[]" }
1839 /// # let registry = OpenApiRegistryImpl::new();
1840 /// # let router: Router<()> = Router::new();
1841 /// let op = OperationBuilder::get("/user-info/v1/users")
1842 /// .anonymous()
1843 /// .handler(list_users)
1844 /// .json_response(StatusCode::OK, "List of users")
1845 /// .standard_errors(®istry);
1846 ///
1847 /// let router = op.register(router, ®istry);
1848 /// # let _ = router;
1849 /// ```
1850 ///
1851 /// This adds the following error responses:
1852 /// - 400 Bad Request
1853 /// - 401 Unauthorized
1854 /// - 403 Forbidden
1855 /// - 404 Not Found
1856 /// - 409 Conflict
1857 /// - 429 Too Many Requests
1858 /// - 500 Internal Server Error
1859 ///
1860 /// 413/415/422 are intentionally absent here: canonical `InvalidArgument`
1861 /// maps to 400 per `docs/arch/errors/DESIGN.md` §1.2, so no
1862 /// canonical-handler path alone produces those statuses. An operation
1863 /// whose handler takes `toolkit::api::rest::extract::Json<T>` can produce
1864 /// all three (oversized body, wrong `Content-Type`, schema violation) -
1865 /// add [`Self::error_413`]/[`Self::error_415`]/[`Self::error_422`]
1866 /// individually for such an operation.
1867 pub fn standard_errors(mut self, registry: &dyn OpenApiRegistry) -> Self {
1868 use http::StatusCode;
1869 // Canonical Problem schema (RFC 9457 + GTS-typed). Component name "Problem".
1870 let problem_name = ensure_schema::<toolkit_canonical_errors::Problem>(registry);
1871
1872 let standard_errors = [
1873 (StatusCode::BAD_REQUEST, "Bad Request"),
1874 (StatusCode::UNAUTHORIZED, "Unauthorized"),
1875 (StatusCode::FORBIDDEN, "Forbidden"),
1876 (StatusCode::NOT_FOUND, "Not Found"),
1877 (StatusCode::CONFLICT, "Conflict"),
1878 (StatusCode::TOO_MANY_REQUESTS, "Too Many Requests"),
1879 (StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error"),
1880 ];
1881
1882 for (status, description) in standard_errors {
1883 self.spec.upsert_response(ResponseSpec {
1884 status: status.as_u16(),
1885 content_type: problem::APPLICATION_PROBLEM_JSON,
1886 description: description.to_owned(),
1887 schema: Some(ResponseSchema::Ref {
1888 schema_name: problem_name.clone(),
1889 }),
1890 headers: Vec::new(),
1891 });
1892 }
1893
1894 self
1895 }
1896
1897 /// Add 400 validation error response using the canonical `Problem` schema.
1898 ///
1899 /// Field-level violations surface under `context.field_violations[]`
1900 /// (canonical `InvalidArgument` category, HTTP 400 per
1901 /// `docs/arch/errors/DESIGN.md` §1.2 / §3.5).
1902 ///
1903 /// # Example
1904 ///
1905 /// ```rust
1906 /// # use axum::Router;
1907 /// # use http::StatusCode;
1908 /// # use toolkit::api::{
1909 /// # openapi_registry::OpenApiRegistryImpl,
1910 /// # operation_builder::OperationBuilder,
1911 /// # };
1912 /// # use serde::{Deserialize, Serialize};
1913 /// # use utoipa::ToSchema;
1914 /// #
1915 /// #[toolkit_macros::api_dto(request)]
1916 /// struct CreateUserRequest {
1917 /// email: String,
1918 /// }
1919 ///
1920 /// # async fn create_user() -> &'static str { "created" }
1921 /// # let registry = OpenApiRegistryImpl::new();
1922 /// # let router: Router<()> = Router::new();
1923 /// let op = OperationBuilder::post("/users-info/v1/users")
1924 /// .anonymous()
1925 /// .handler(create_user)
1926 /// .json_request::<CreateUserRequest>(®istry, "User data")
1927 /// .json_response(StatusCode::CREATED, "User created")
1928 /// .with_400_validation_error(®istry);
1929 ///
1930 /// let router = op.register(router, ®istry);
1931 /// # let _ = router;
1932 /// ```
1933 pub fn with_400_validation_error(mut self, registry: &dyn OpenApiRegistry) -> Self {
1934 let problem_name = ensure_schema::<toolkit_canonical_errors::Problem>(registry);
1935
1936 self.spec.upsert_response(ResponseSpec {
1937 status: http::StatusCode::BAD_REQUEST.as_u16(),
1938 content_type: problem::APPLICATION_PROBLEM_JSON,
1939 description: "Validation Error".to_owned(),
1940 schema: Some(ResponseSchema::Ref {
1941 schema_name: problem_name,
1942 }),
1943 headers: Vec::new(),
1944 });
1945
1946 self
1947 }
1948
1949 /// Add a 400 Bad Request error response.
1950 ///
1951 /// This is a convenience wrapper around `problem_response`.
1952 pub fn error_400(self, registry: &dyn OpenApiRegistry) -> Self {
1953 self.problem_response(registry, http::StatusCode::BAD_REQUEST, "Bad Request")
1954 }
1955
1956 /// Add a 401 Unauthorized error response.
1957 ///
1958 /// This is a convenience wrapper around `problem_response`.
1959 pub fn error_401(self, registry: &dyn OpenApiRegistry) -> Self {
1960 self.problem_response(registry, http::StatusCode::UNAUTHORIZED, "Unauthorized")
1961 }
1962
1963 /// Add a 403 Forbidden error response.
1964 ///
1965 /// This is a convenience wrapper around `problem_response`.
1966 pub fn error_403(self, registry: &dyn OpenApiRegistry) -> Self {
1967 self.problem_response(registry, http::StatusCode::FORBIDDEN, "Forbidden")
1968 }
1969
1970 /// Add a 404 Not Found error response.
1971 ///
1972 /// This is a convenience wrapper around `problem_response`.
1973 pub fn error_404(self, registry: &dyn OpenApiRegistry) -> Self {
1974 self.problem_response(registry, http::StatusCode::NOT_FOUND, "Not Found")
1975 }
1976
1977 /// Add a 409 Conflict error response.
1978 ///
1979 /// This is a convenience wrapper around `problem_response`.
1980 pub fn error_409(self, registry: &dyn OpenApiRegistry) -> Self {
1981 self.problem_response(registry, http::StatusCode::CONFLICT, "Conflict")
1982 }
1983
1984 /// Add a 413 Payload Too Large error response.
1985 ///
1986 /// This is a convenience wrapper around `problem_response`. Relevant to
1987 /// any operation whose handler takes
1988 /// [`toolkit::api::rest::extract::Json<T>`](crate::api::rest::extract::Json)
1989 /// as a parameter - an oversized request body produces this status.
1990 pub fn error_413(self, registry: &dyn OpenApiRegistry) -> Self {
1991 self.problem_response(
1992 registry,
1993 http::StatusCode::PAYLOAD_TOO_LARGE,
1994 "Payload Too Large",
1995 )
1996 }
1997
1998 /// Add a 415 Unsupported Media Type error response.
1999 ///
2000 /// This is a convenience wrapper around `problem_response`.
2001 pub fn error_415(self, registry: &dyn OpenApiRegistry) -> Self {
2002 self.problem_response(
2003 registry,
2004 http::StatusCode::UNSUPPORTED_MEDIA_TYPE,
2005 "Unsupported Media Type",
2006 )
2007 }
2008
2009 /// Add a 422 Unprocessable Entity error response.
2010 ///
2011 /// This is a convenience wrapper around `problem_response`.
2012 pub fn error_422(self, registry: &dyn OpenApiRegistry) -> Self {
2013 self.problem_response(
2014 registry,
2015 http::StatusCode::UNPROCESSABLE_ENTITY,
2016 "Unprocessable Entity",
2017 )
2018 }
2019
2020 /// Add a 429 Too Many Requests error response.
2021 ///
2022 /// This is a convenience wrapper around `problem_response`.
2023 pub fn error_429(self, registry: &dyn OpenApiRegistry) -> Self {
2024 self.problem_response(
2025 registry,
2026 http::StatusCode::TOO_MANY_REQUESTS,
2027 "Too Many Requests",
2028 )
2029 }
2030
2031 /// Add a 500 Internal Server Error response.
2032 ///
2033 /// This is a convenience wrapper around `problem_response`.
2034 pub fn error_500(self, registry: &dyn OpenApiRegistry) -> Self {
2035 self.problem_response(
2036 registry,
2037 http::StatusCode::INTERNAL_SERVER_ERROR,
2038 "Internal Server Error",
2039 )
2040 }
2041
2042 /// Add a 502 Bad Gateway error response.
2043 ///
2044 /// This is a convenience wrapper around `problem_response`.
2045 pub fn error_502(self, registry: &dyn OpenApiRegistry) -> Self {
2046 self.problem_response(registry, http::StatusCode::BAD_GATEWAY, "Bad Gateway")
2047 }
2048
2049 /// Add a 503 Service Unavailable error response.
2050 ///
2051 /// This is a convenience wrapper around `problem_response`.
2052 pub fn error_503(self, registry: &dyn OpenApiRegistry) -> Self {
2053 self.problem_response(
2054 registry,
2055 http::StatusCode::SERVICE_UNAVAILABLE,
2056 "Service Unavailable",
2057 )
2058 }
2059
2060 /// Add a 504 Gateway Timeout error response.
2061 ///
2062 /// This is a convenience wrapper around `problem_response`.
2063 pub fn error_504(self, registry: &dyn OpenApiRegistry) -> Self {
2064 self.problem_response(
2065 registry,
2066 http::StatusCode::GATEWAY_TIMEOUT,
2067 "Gateway Timeout",
2068 )
2069 }
2070}
2071
2072// -------------------------------------------------------------------------------------------------
2073// Registration — only available when handler, response, AND auth are all set
2074// -------------------------------------------------------------------------------------------------
2075impl<S> OperationBuilder<Present, Present, S, AuthSet, LicenseSet>
2076where
2077 S: Clone + Send + Sync + 'static,
2078{
2079 /// Register the operation with the router and `OpenAPI` registry.
2080 ///
2081 /// This method is only available when:
2082 /// - Handler is present
2083 /// - Response is present
2084 /// - Auth requirement is set (either `authenticated` or `public`)
2085 ///
2086 /// All conditions are enforced at compile time by the type system.
2087 pub fn register(self, router: Router<S>, openapi: &dyn OpenApiRegistry) -> Router<S> {
2088 // Inform the OpenAPI registry (the implementation will translate OperationSpec
2089 // into an OpenAPI Operation + RequestBody + Responses with component refs).
2090 openapi.register_operation(&self.spec);
2091
2092 // In Present state the method_router is guaranteed to be a real MethodRouter<S>.
2093 router.route(&self.spec.path, self.method_router)
2094 }
2095}
2096
2097// -------------------------------------------------------------------------------------------------
2098// Tests
2099// -------------------------------------------------------------------------------------------------
2100#[cfg(test)]
2101#[cfg_attr(coverage_nightly, coverage(off))]
2102#[path = "operation_builder_tests.rs"]
2103mod tests;