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