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