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