Skip to main content

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