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