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