Skip to main content

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