Skip to main content

modkit/api/
operation_builder.rs

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