Skip to main content

toolkit/api/
operation_builder.rs

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