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