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    /// Explicitly declare that this operation does not require any license.
850    ///
851    /// Use this for system/infrastructure endpoints that need authentication
852    /// but are not gated behind application-level license features.
853    ///
854    /// This transitions from `LicenseNotSet` to `LicenseSet` without
855    /// attaching any license requirement.
856    pub fn no_license_required(self) -> OperationBuilder<H, R, S, AuthSet, LicenseSet> {
857        OperationBuilder {
858            spec: self.spec,
859            method_router: self.method_router,
860            _has_handler: self._has_handler,
861            _has_response: self._has_response,
862            _state: self._state,
863            _auth_state: self._auth_state,
864            _license_state: PhantomData,
865        }
866    }
867}
868
869// -------------------------------------------------------------------------------------------------
870// Auth requirement setting — transitions AuthNotSet -> AuthSet
871// -------------------------------------------------------------------------------------------------
872impl<H, R, S, L> OperationBuilder<H, R, S, AuthNotSet, L>
873where
874    H: HandlerSlot<S>,
875    L: LicenseState,
876{
877    /// Mark this route as requiring authentication.
878    ///
879    /// This is a binary marker — the route requires a valid bearer token.
880    /// Scope enforcement (which scopes are needed) is configured at the
881    /// gateway level, not per-route.
882    ///
883    /// This method transitions from `AuthNotSet` to `AuthSet` state.
884    ///
885    /// # Example
886    /// ```rust
887    /// # use modkit::api::operation_builder::{OperationBuilder, LicenseFeature};
888    /// # use axum::{extract::Json, Router };
889    /// # use serde::{Serialize};
890    /// #
891    /// # #[derive(Serialize)]
892    /// # pub struct User;
893    /// #
894    /// enum License {
895    ///     Base,
896    /// }
897    ///
898    /// impl AsRef<str> for License {
899    ///     fn as_ref(&self) -> &str {
900    ///         match self {
901    ///             License::Base => "gts.x.core.lic.feat.v1~x.core.global.base.v1",
902    ///         }
903    ///     }
904    /// }
905    ///
906    /// impl LicenseFeature for License {}
907    ///
908    /// #
909    /// # fn register_rest(
910    /// #   router: axum::Router,
911    /// #   api: &dyn modkit::api::OpenApiRegistry,
912    /// # ) -> anyhow::Result<axum::Router> {
913    /// let router = OperationBuilder::get("/users-info/v1/users")
914    ///     .authenticated()
915    ///     .require_license_features::<License>([])
916    ///     .handler(list_users_handler)
917    ///     .json_response(axum::http::StatusCode::OK, "List of users")
918    ///     .register(router, api);
919    /// #  Ok(router)
920    /// # }
921    ///
922    /// # async fn list_users_handler() -> Json<Vec<User>> {
923    /// #   unimplemented!()
924    /// # }
925    /// ```
926    pub fn authenticated(mut self) -> OperationBuilder<H, R, S, AuthSet, L> {
927        self.spec.authenticated = true;
928        self.spec.is_public = false;
929        OperationBuilder {
930            spec: self.spec,
931            method_router: self.method_router,
932            _has_handler: self._has_handler,
933            _has_response: self._has_response,
934            _state: self._state,
935            _auth_state: PhantomData,
936            _license_state: self._license_state,
937        }
938    }
939
940    /// Mark this route as public (no authentication required).
941    ///
942    /// This explicitly opts out of the `require_auth_by_default` setting.
943    /// This method transitions from `AuthNotSet` to `AuthSet` state.
944    ///
945    /// # Example
946    /// ```rust
947    /// # use axum::Router;
948    /// # use http::StatusCode;
949    /// # use modkit::api::{
950    /// #     openapi_registry::OpenApiRegistryImpl,
951    /// #     operation_builder::OperationBuilder,
952    /// # };
953    /// # async fn health_check() -> &'static str { "OK" }
954    /// # let registry = OpenApiRegistryImpl::new();
955    /// # let router: Router<()> = Router::new();
956    /// let router = OperationBuilder::get("/users-info/v1/health")
957    ///     .public()
958    ///     .handler(health_check)
959    ///     .json_response(StatusCode::OK, "OK")
960    ///     .register(router, &registry);
961    /// # let _ = router;
962    /// ```
963    pub fn public(mut self) -> OperationBuilder<H, R, S, AuthSet, LicenseSet> {
964        self.spec.is_public = true;
965        self.spec.authenticated = false;
966        OperationBuilder {
967            spec: self.spec,
968            method_router: self.method_router,
969            _has_handler: self._has_handler,
970            _has_response: self._has_response,
971            _state: self._state,
972            _auth_state: PhantomData,
973            _license_state: PhantomData,
974        }
975    }
976}
977
978// -------------------------------------------------------------------------------------------------
979// Handler setting — transitions Missing -> Present for handler
980// -------------------------------------------------------------------------------------------------
981impl<R, S, A, L> OperationBuilder<Missing, R, S, A, L>
982where
983    S: Clone + Send + Sync + 'static,
984    A: AuthState,
985    L: LicenseState,
986{
987    /// Set the handler for this operation (function handlers are recommended).
988    ///
989    /// This transitions the builder from `Missing` to `Present` handler state.
990    pub fn handler<F, T>(self, h: F) -> OperationBuilder<Present, R, S, A, L>
991    where
992        F: Handler<T, S> + Clone + Send + 'static,
993        T: 'static,
994    {
995        let method_router = match self.spec.method {
996            Method::GET => axum::routing::get(h),
997            Method::POST => axum::routing::post(h),
998            Method::PUT => axum::routing::put(h),
999            Method::DELETE => axum::routing::delete(h),
1000            Method::PATCH => axum::routing::patch(h),
1001            _ => axum::routing::any(|| async { axum::http::StatusCode::METHOD_NOT_ALLOWED }),
1002        };
1003
1004        OperationBuilder {
1005            spec: self.spec,
1006            method_router, // concrete MethodRouter<S> in Present state
1007            _has_handler: PhantomData::<Present>,
1008            _has_response: self._has_response,
1009            _state: self._state,
1010            _auth_state: self._auth_state,
1011            _license_state: self._license_state,
1012        }
1013    }
1014
1015    /// Alternative path: provide a pre-composed `MethodRouter<S>` yourself
1016    /// (useful to attach per-route middleware/layers).
1017    pub fn method_router(self, mr: MethodRouter<S>) -> OperationBuilder<Present, R, S, A, L> {
1018        OperationBuilder {
1019            spec: self.spec,
1020            method_router: mr, // concrete MethodRouter<S> in Present state
1021            _has_handler: PhantomData::<Present>,
1022            _has_response: self._has_response,
1023            _state: self._state,
1024            _auth_state: self._auth_state,
1025            _license_state: self._license_state,
1026        }
1027    }
1028}
1029
1030// -------------------------------------------------------------------------------------------------
1031// Response setting — transitions Missing -> Present for response (first response)
1032// -------------------------------------------------------------------------------------------------
1033impl<H, S, A, L> OperationBuilder<H, Missing, S, A, L>
1034where
1035    H: HandlerSlot<S>,
1036    A: AuthState,
1037    L: LicenseState,
1038{
1039    /// Add a raw response spec (transitions from Missing to Present).
1040    pub fn response(mut self, resp: ResponseSpec) -> OperationBuilder<H, Present, S, A, L> {
1041        self.spec.responses.push(resp);
1042        OperationBuilder {
1043            spec: self.spec,
1044            method_router: self.method_router,
1045            _has_handler: self._has_handler,
1046            _has_response: PhantomData::<Present>,
1047            _state: self._state,
1048            _auth_state: self._auth_state,
1049            _license_state: self._license_state,
1050        }
1051    }
1052
1053    /// Add a JSON response (transitions from Missing to Present).
1054    pub fn json_response(
1055        mut self,
1056        status: http::StatusCode,
1057        description: impl Into<String>,
1058    ) -> OperationBuilder<H, Present, S, A, L> {
1059        self.spec.responses.push(ResponseSpec {
1060            status: status.as_u16(),
1061            content_type: "application/json",
1062            description: description.into(),
1063            schema_name: None,
1064        });
1065        OperationBuilder {
1066            spec: self.spec,
1067            method_router: self.method_router,
1068            _has_handler: self._has_handler,
1069            _has_response: PhantomData::<Present>,
1070            _state: self._state,
1071            _auth_state: self._auth_state,
1072            _license_state: self._license_state,
1073        }
1074    }
1075
1076    /// Add a JSON response with a registered schema (transitions from Missing to Present).
1077    pub fn json_response_with_schema<T>(
1078        mut self,
1079        registry: &dyn OpenApiRegistry,
1080        status: http::StatusCode,
1081        description: impl Into<String>,
1082    ) -> OperationBuilder<H, Present, S, A, L>
1083    where
1084        T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
1085    {
1086        let name = ensure_schema::<T>(registry);
1087        self.spec.responses.push(ResponseSpec {
1088            status: status.as_u16(),
1089            content_type: "application/json",
1090            description: description.into(),
1091            schema_name: Some(name),
1092        });
1093        OperationBuilder {
1094            spec: self.spec,
1095            method_router: self.method_router,
1096            _has_handler: self._has_handler,
1097            _has_response: PhantomData::<Present>,
1098            _state: self._state,
1099            _auth_state: self._auth_state,
1100            _license_state: self._license_state,
1101        }
1102    }
1103
1104    /// Add a text response with a custom content type (transitions from Missing to Present).
1105    ///
1106    /// # Arguments
1107    /// * `status` - HTTP status code
1108    /// * `description` - Description of the response
1109    /// * `content_type` - **Pure media type without parameters** (e.g., `"text/plain"`, `"text/markdown"`)
1110    ///
1111    /// # Important
1112    /// The `content_type` must be a pure media type **without parameters** like `; charset=utf-8`.
1113    /// `OpenAPI` media type keys cannot include parameters. Use `"text/markdown"` instead of
1114    /// `"text/markdown; charset=utf-8"`. Actual HTTP response headers in handlers should still
1115    /// include the charset parameter.
1116    pub fn text_response(
1117        mut self,
1118        status: http::StatusCode,
1119        description: impl Into<String>,
1120        content_type: &'static str,
1121    ) -> OperationBuilder<H, Present, S, A, L> {
1122        self.spec.responses.push(ResponseSpec {
1123            status: status.as_u16(),
1124            content_type,
1125            description: description.into(),
1126            schema_name: None,
1127        });
1128        OperationBuilder {
1129            spec: self.spec,
1130            method_router: self.method_router,
1131            _has_handler: self._has_handler,
1132            _has_response: PhantomData::<Present>,
1133            _state: self._state,
1134            _auth_state: self._auth_state,
1135            _license_state: self._license_state,
1136        }
1137    }
1138
1139    /// Add an HTML response (transitions from Missing to Present).
1140    pub fn html_response(
1141        mut self,
1142        status: http::StatusCode,
1143        description: impl Into<String>,
1144    ) -> OperationBuilder<H, Present, S, A, L> {
1145        self.spec.responses.push(ResponseSpec {
1146            status: status.as_u16(),
1147            content_type: "text/html",
1148            description: description.into(),
1149            schema_name: None,
1150        });
1151        OperationBuilder {
1152            spec: self.spec,
1153            method_router: self.method_router,
1154            _has_handler: self._has_handler,
1155            _has_response: PhantomData::<Present>,
1156            _state: self._state,
1157            _auth_state: self._auth_state,
1158            _license_state: self._license_state,
1159        }
1160    }
1161
1162    /// Add an RFC 9457 `application/problem+json` response (transitions from Missing to Present).
1163    pub fn problem_response(
1164        mut self,
1165        registry: &dyn OpenApiRegistry,
1166        status: http::StatusCode,
1167        description: impl Into<String>,
1168    ) -> OperationBuilder<H, Present, S, A, L> {
1169        // Ensure `Problem` schema is registered in components
1170        let problem_name = ensure_schema::<crate::api::problem::Problem>(registry);
1171        self.spec.responses.push(ResponseSpec {
1172            status: status.as_u16(),
1173            content_type: problem::APPLICATION_PROBLEM_JSON,
1174            description: description.into(),
1175            schema_name: Some(problem_name),
1176        });
1177        OperationBuilder {
1178            spec: self.spec,
1179            method_router: self.method_router,
1180            _has_handler: self._has_handler,
1181            _has_response: PhantomData::<Present>,
1182            _state: self._state,
1183            _auth_state: self._auth_state,
1184            _license_state: self._license_state,
1185        }
1186    }
1187
1188    /// First response: SSE stream of JSON events (`text/event-stream`).
1189    pub fn sse_json<T>(
1190        mut self,
1191        openapi: &dyn OpenApiRegistry,
1192        description: impl Into<String>,
1193    ) -> OperationBuilder<H, Present, S, A, L>
1194    where
1195        T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
1196    {
1197        let name = ensure_schema::<T>(openapi);
1198        self.spec.responses.push(ResponseSpec {
1199            status: http::StatusCode::OK.as_u16(),
1200            content_type: "text/event-stream",
1201            description: description.into(),
1202            schema_name: Some(name),
1203        });
1204        OperationBuilder {
1205            spec: self.spec,
1206            method_router: self.method_router,
1207            _has_handler: self._has_handler,
1208            _has_response: PhantomData::<Present>,
1209            _state: self._state,
1210            _auth_state: self._auth_state,
1211            _license_state: self._license_state,
1212        }
1213    }
1214}
1215
1216// -------------------------------------------------------------------------------------------------
1217// Additional responses — for Present response state (additional responses)
1218// -------------------------------------------------------------------------------------------------
1219impl<H, S, A, L> OperationBuilder<H, Present, S, A, L>
1220where
1221    H: HandlerSlot<S>,
1222    A: AuthState,
1223    L: LicenseState,
1224{
1225    /// Add a JSON response (additional).
1226    pub fn json_response(
1227        mut self,
1228        status: http::StatusCode,
1229        description: impl Into<String>,
1230    ) -> Self {
1231        self.spec.responses.push(ResponseSpec {
1232            status: status.as_u16(),
1233            content_type: "application/json",
1234            description: description.into(),
1235            schema_name: None,
1236        });
1237        self
1238    }
1239
1240    /// Add a JSON response with a registered schema (additional).
1241    pub fn json_response_with_schema<T>(
1242        mut self,
1243        registry: &dyn OpenApiRegistry,
1244        status: http::StatusCode,
1245        description: impl Into<String>,
1246    ) -> Self
1247    where
1248        T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
1249    {
1250        let name = ensure_schema::<T>(registry);
1251        self.spec.responses.push(ResponseSpec {
1252            status: status.as_u16(),
1253            content_type: "application/json",
1254            description: description.into(),
1255            schema_name: Some(name),
1256        });
1257        self
1258    }
1259
1260    /// Add a text response with a custom content type (additional).
1261    ///
1262    /// # Arguments
1263    /// * `status` - HTTP status code
1264    /// * `description` - Description of the response
1265    /// * `content_type` - **Pure media type without parameters** (e.g., `"text/plain"`, `"text/markdown"`)
1266    ///
1267    /// # Important
1268    /// The `content_type` must be a pure media type **without parameters** like `; charset=utf-8`.
1269    /// `OpenAPI` media type keys cannot include parameters. Use `"text/markdown"` instead of
1270    /// `"text/markdown; charset=utf-8"`. Actual HTTP response headers in handlers should still
1271    /// include the charset parameter.
1272    pub fn text_response(
1273        mut self,
1274        status: http::StatusCode,
1275        description: impl Into<String>,
1276        content_type: &'static str,
1277    ) -> Self {
1278        self.spec.responses.push(ResponseSpec {
1279            status: status.as_u16(),
1280            content_type,
1281            description: description.into(),
1282            schema_name: None,
1283        });
1284        self
1285    }
1286
1287    /// Add an HTML response (additional).
1288    pub fn html_response(
1289        mut self,
1290        status: http::StatusCode,
1291        description: impl Into<String>,
1292    ) -> Self {
1293        self.spec.responses.push(ResponseSpec {
1294            status: status.as_u16(),
1295            content_type: "text/html",
1296            description: description.into(),
1297            schema_name: None,
1298        });
1299        self
1300    }
1301
1302    /// Add an additional RFC 9457 `application/problem+json` response.
1303    pub fn problem_response(
1304        mut self,
1305        registry: &dyn OpenApiRegistry,
1306        status: http::StatusCode,
1307        description: impl Into<String>,
1308    ) -> Self {
1309        let problem_name = ensure_schema::<crate::api::problem::Problem>(registry);
1310        self.spec.responses.push(ResponseSpec {
1311            status: status.as_u16(),
1312            content_type: problem::APPLICATION_PROBLEM_JSON,
1313            description: description.into(),
1314            schema_name: Some(problem_name),
1315        });
1316        self
1317    }
1318
1319    /// Additional SSE response (if the operation already has a response).
1320    pub fn sse_json<T>(
1321        mut self,
1322        openapi: &dyn OpenApiRegistry,
1323        description: impl Into<String>,
1324    ) -> Self
1325    where
1326        T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
1327    {
1328        let name = ensure_schema::<T>(openapi);
1329        self.spec.responses.push(ResponseSpec {
1330            status: http::StatusCode::OK.as_u16(),
1331            content_type: "text/event-stream",
1332            description: description.into(),
1333            schema_name: Some(name),
1334        });
1335        self
1336    }
1337
1338    /// Add standard error responses (400, 401, 403, 404, 409, 422, 429, 500).
1339    ///
1340    /// All responses reference the shared Problem schema (RFC 9457) for consistent
1341    /// error handling across your API. This is the recommended way to declare
1342    /// common error responses without repeating boilerplate.
1343    ///
1344    /// # Example
1345    ///
1346    /// ```rust
1347    /// # use axum::Router;
1348    /// # use http::StatusCode;
1349    /// # use modkit::api::{
1350    /// #     openapi_registry::OpenApiRegistryImpl,
1351    /// #     operation_builder::OperationBuilder,
1352    /// # };
1353    /// # async fn list_users() -> &'static str { "[]" }
1354    /// # let registry = OpenApiRegistryImpl::new();
1355    /// # let router: Router<()> = Router::new();
1356    /// let op = OperationBuilder::get("/user-info/v1/users")
1357    ///     .public()
1358    ///     .handler(list_users)
1359    ///     .json_response(StatusCode::OK, "List of users")
1360    ///     .standard_errors(&registry);
1361    ///
1362    /// let router = op.register(router, &registry);
1363    /// # let _ = router;
1364    /// ```
1365    ///
1366    /// This adds the following error responses:
1367    /// - 400 Bad Request
1368    /// - 401 Unauthorized
1369    /// - 403 Forbidden
1370    /// - 404 Not Found
1371    /// - 409 Conflict
1372    /// - 422 Unprocessable Entity
1373    /// - 429 Too Many Requests
1374    /// - 500 Internal Server Error
1375    pub fn standard_errors(mut self, registry: &dyn OpenApiRegistry) -> Self {
1376        use http::StatusCode;
1377        let problem_name = ensure_schema::<crate::api::problem::Problem>(registry);
1378
1379        let standard_errors = [
1380            (StatusCode::BAD_REQUEST, "Bad Request"),
1381            (StatusCode::UNAUTHORIZED, "Unauthorized"),
1382            (StatusCode::FORBIDDEN, "Forbidden"),
1383            (StatusCode::NOT_FOUND, "Not Found"),
1384            (StatusCode::CONFLICT, "Conflict"),
1385            (StatusCode::UNPROCESSABLE_ENTITY, "Unprocessable Entity"),
1386            (StatusCode::TOO_MANY_REQUESTS, "Too Many Requests"),
1387            (StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error"),
1388        ];
1389
1390        for (status, description) in standard_errors {
1391            self.spec.responses.push(ResponseSpec {
1392                status: status.as_u16(),
1393                content_type: problem::APPLICATION_PROBLEM_JSON,
1394                description: description.to_owned(),
1395                schema_name: Some(problem_name.clone()),
1396            });
1397        }
1398
1399        self
1400    }
1401
1402    /// Add 422 validation error response using `ValidationError` schema.
1403    ///
1404    /// This method adds a specific 422 Unprocessable Entity response that uses
1405    /// the `ValidationError` schema instead of the generic Problem schema. Use this
1406    /// for endpoints that perform input validation and need structured error details.
1407    ///
1408    /// # Example
1409    ///
1410    /// ```rust
1411    /// # use axum::Router;
1412    /// # use http::StatusCode;
1413    /// # use modkit::api::{
1414    /// #     openapi_registry::OpenApiRegistryImpl,
1415    /// #     operation_builder::OperationBuilder,
1416    /// # };
1417    /// # use serde::{Deserialize, Serialize};
1418    /// # use utoipa::ToSchema;
1419    /// #
1420    /// #[modkit_macros::api_dto(request)]
1421    /// struct CreateUserRequest {
1422    ///     email: String,
1423    /// }
1424    ///
1425    /// # async fn create_user() -> &'static str { "created" }
1426    /// # let registry = OpenApiRegistryImpl::new();
1427    /// # let router: Router<()> = Router::new();
1428    /// let op = OperationBuilder::post("/users-info/v1/users")
1429    ///     .public()
1430    ///     .handler(create_user)
1431    ///     .json_request::<CreateUserRequest>(&registry, "User data")
1432    ///     .json_response(StatusCode::CREATED, "User created")
1433    ///     .with_422_validation_error(&registry);
1434    ///
1435    /// let router = op.register(router, &registry);
1436    /// # let _ = router;
1437    /// ```
1438    pub fn with_422_validation_error(mut self, registry: &dyn OpenApiRegistry) -> Self {
1439        let validation_error_name =
1440            ensure_schema::<crate::api::problem::ValidationErrorResponse>(registry);
1441
1442        self.spec.responses.push(ResponseSpec {
1443            status: http::StatusCode::UNPROCESSABLE_ENTITY.as_u16(),
1444            content_type: problem::APPLICATION_PROBLEM_JSON,
1445            description: "Validation Error".to_owned(),
1446            schema_name: Some(validation_error_name),
1447        });
1448
1449        self
1450    }
1451
1452    /// Add a 400 Bad Request error response.
1453    ///
1454    /// This is a convenience wrapper around `problem_response`.
1455    pub fn error_400(self, registry: &dyn OpenApiRegistry) -> Self {
1456        self.problem_response(registry, http::StatusCode::BAD_REQUEST, "Bad Request")
1457    }
1458
1459    /// Add a 401 Unauthorized error response.
1460    ///
1461    /// This is a convenience wrapper around `problem_response`.
1462    pub fn error_401(self, registry: &dyn OpenApiRegistry) -> Self {
1463        self.problem_response(registry, http::StatusCode::UNAUTHORIZED, "Unauthorized")
1464    }
1465
1466    /// Add a 403 Forbidden error response.
1467    ///
1468    /// This is a convenience wrapper around `problem_response`.
1469    pub fn error_403(self, registry: &dyn OpenApiRegistry) -> Self {
1470        self.problem_response(registry, http::StatusCode::FORBIDDEN, "Forbidden")
1471    }
1472
1473    /// Add a 404 Not Found error response.
1474    ///
1475    /// This is a convenience wrapper around `problem_response`.
1476    pub fn error_404(self, registry: &dyn OpenApiRegistry) -> Self {
1477        self.problem_response(registry, http::StatusCode::NOT_FOUND, "Not Found")
1478    }
1479
1480    /// Add a 409 Conflict error response.
1481    ///
1482    /// This is a convenience wrapper around `problem_response`.
1483    pub fn error_409(self, registry: &dyn OpenApiRegistry) -> Self {
1484        self.problem_response(registry, http::StatusCode::CONFLICT, "Conflict")
1485    }
1486
1487    /// Add a 415 Unsupported Media Type error response.
1488    ///
1489    /// This is a convenience wrapper around `problem_response`.
1490    pub fn error_415(self, registry: &dyn OpenApiRegistry) -> Self {
1491        self.problem_response(
1492            registry,
1493            http::StatusCode::UNSUPPORTED_MEDIA_TYPE,
1494            "Unsupported Media Type",
1495        )
1496    }
1497
1498    /// Add a 422 Unprocessable Entity error response.
1499    ///
1500    /// This is a convenience wrapper around `problem_response`.
1501    pub fn error_422(self, registry: &dyn OpenApiRegistry) -> Self {
1502        self.problem_response(
1503            registry,
1504            http::StatusCode::UNPROCESSABLE_ENTITY,
1505            "Unprocessable Entity",
1506        )
1507    }
1508
1509    /// Add a 429 Too Many Requests error response.
1510    ///
1511    /// This is a convenience wrapper around `problem_response`.
1512    pub fn error_429(self, registry: &dyn OpenApiRegistry) -> Self {
1513        self.problem_response(
1514            registry,
1515            http::StatusCode::TOO_MANY_REQUESTS,
1516            "Too Many Requests",
1517        )
1518    }
1519
1520    /// Add a 500 Internal Server Error response.
1521    ///
1522    /// This is a convenience wrapper around `problem_response`.
1523    pub fn error_500(self, registry: &dyn OpenApiRegistry) -> Self {
1524        self.problem_response(
1525            registry,
1526            http::StatusCode::INTERNAL_SERVER_ERROR,
1527            "Internal Server Error",
1528        )
1529    }
1530}
1531
1532// -------------------------------------------------------------------------------------------------
1533// Registration — only available when handler, response, AND auth are all set
1534// -------------------------------------------------------------------------------------------------
1535impl<S> OperationBuilder<Present, Present, S, AuthSet, LicenseSet>
1536where
1537    S: Clone + Send + Sync + 'static,
1538{
1539    /// Register the operation with the router and `OpenAPI` registry.
1540    ///
1541    /// This method is only available when:
1542    /// - Handler is present
1543    /// - Response is present
1544    /// - Auth requirement is set (either `authenticated` or `public`)
1545    ///
1546    /// All conditions are enforced at compile time by the type system.
1547    pub fn register(self, router: Router<S>, openapi: &dyn OpenApiRegistry) -> Router<S> {
1548        // Inform the OpenAPI registry (the implementation will translate OperationSpec
1549        // into an OpenAPI Operation + RequestBody + Responses with component refs).
1550        openapi.register_operation(&self.spec);
1551
1552        // In Present state the method_router is guaranteed to be a real MethodRouter<S>.
1553        router.route(&self.spec.path, self.method_router)
1554    }
1555}
1556
1557// -------------------------------------------------------------------------------------------------
1558// Tests
1559// -------------------------------------------------------------------------------------------------
1560#[cfg(test)]
1561#[cfg_attr(coverage_nightly, coverage(off))]
1562mod tests {
1563    use super::*;
1564    use axum::Json;
1565
1566    // Mock registry for testing: stores operations; records schema names
1567    struct MockRegistry {
1568        operations: std::sync::Mutex<Vec<OperationSpec>>,
1569        schemas: std::sync::Mutex<Vec<String>>,
1570    }
1571
1572    impl MockRegistry {
1573        fn new() -> Self {
1574            Self {
1575                operations: std::sync::Mutex::new(Vec::new()),
1576                schemas: std::sync::Mutex::new(Vec::new()),
1577            }
1578        }
1579    }
1580
1581    enum TestLicenseFeatures {
1582        FeatureA,
1583        FeatureB,
1584    }
1585    impl AsRef<str> for TestLicenseFeatures {
1586        fn as_ref(&self) -> &str {
1587            match self {
1588                TestLicenseFeatures::FeatureA => "feature_a",
1589                TestLicenseFeatures::FeatureB => "feature_b",
1590            }
1591        }
1592    }
1593    impl LicenseFeature for TestLicenseFeatures {}
1594
1595    impl OpenApiRegistry for MockRegistry {
1596        fn register_operation(&self, spec: &OperationSpec) {
1597            if let Ok(mut ops) = self.operations.lock() {
1598                ops.push(spec.clone());
1599            }
1600        }
1601
1602        fn ensure_schema_raw(
1603            &self,
1604            name: &str,
1605            _schemas: Vec<(
1606                String,
1607                utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>,
1608            )>,
1609        ) -> String {
1610            let name = name.to_owned();
1611            if let Ok(mut s) = self.schemas.lock() {
1612                s.push(name.clone());
1613            }
1614            name
1615        }
1616
1617        fn as_any(&self) -> &dyn std::any::Any {
1618            self
1619        }
1620    }
1621
1622    async fn test_handler() -> Json<serde_json::Value> {
1623        Json(serde_json::json!({"status": "ok"}))
1624    }
1625
1626    #[modkit_macros::api_dto(request)]
1627    struct SampleDtoRequest;
1628
1629    #[modkit_macros::api_dto(response)]
1630    struct SampleDtoResponse;
1631
1632    #[test]
1633    fn builder_descriptive_methods() {
1634        let builder = OperationBuilder::<Missing, Missing, (), AuthNotSet>::get("/tests/v1/test")
1635            .operation_id("test.get")
1636            .summary("Test endpoint")
1637            .description("A test endpoint for validation")
1638            .tag("test")
1639            .path_param("id", "Test ID");
1640
1641        assert_eq!(builder.spec.method, Method::GET);
1642        assert_eq!(builder.spec.path, "/tests/v1/test");
1643        assert_eq!(builder.spec.operation_id, Some("test.get".to_owned()));
1644        assert_eq!(builder.spec.summary, Some("Test endpoint".to_owned()));
1645        assert_eq!(
1646            builder.spec.description,
1647            Some("A test endpoint for validation".to_owned())
1648        );
1649        assert_eq!(builder.spec.tags, vec!["test"]);
1650        assert_eq!(builder.spec.params.len(), 1);
1651    }
1652
1653    #[tokio::test]
1654    async fn builder_with_request_response_and_handler() {
1655        let registry = MockRegistry::new();
1656        let router = Router::new();
1657
1658        let _router = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
1659            .summary("Test endpoint")
1660            .json_request::<SampleDtoRequest>(&registry, "optional body") // registers schema
1661            .public()
1662            .handler(test_handler)
1663            .json_response_with_schema::<SampleDtoResponse>(
1664                &registry,
1665                http::StatusCode::OK,
1666                "Success response",
1667            ) // registers schema
1668            .register(router, &registry);
1669
1670        // Verify that the operation was registered
1671        let ops = registry.operations.lock().unwrap();
1672        assert_eq!(ops.len(), 1);
1673        let op = &ops[0];
1674        assert_eq!(op.method, Method::POST);
1675        assert_eq!(op.path, "/tests/v1/test");
1676        assert!(op.request_body.is_some());
1677        assert!(op.request_body.as_ref().unwrap().required);
1678        assert_eq!(op.responses.len(), 1);
1679        assert_eq!(op.responses[0].status, 200);
1680
1681        // Verify schemas recorded
1682        let schemas = registry.schemas.lock().unwrap();
1683        assert!(!schemas.is_empty());
1684    }
1685
1686    #[test]
1687    fn convenience_constructors() {
1688        let get_builder =
1689            OperationBuilder::<Missing, Missing, (), AuthNotSet>::get("/tests/v1/get");
1690        assert_eq!(get_builder.spec.method, Method::GET);
1691        assert_eq!(get_builder.spec.path, "/tests/v1/get");
1692
1693        let post_builder =
1694            OperationBuilder::<Missing, Missing, (), AuthNotSet>::post("/tests/v1/post");
1695        assert_eq!(post_builder.spec.method, Method::POST);
1696        assert_eq!(post_builder.spec.path, "/tests/v1/post");
1697
1698        let put_builder =
1699            OperationBuilder::<Missing, Missing, (), AuthNotSet>::put("/tests/v1/put");
1700        assert_eq!(put_builder.spec.method, Method::PUT);
1701        assert_eq!(put_builder.spec.path, "/tests/v1/put");
1702
1703        let delete_builder =
1704            OperationBuilder::<Missing, Missing, (), AuthNotSet>::delete("/tests/v1/delete");
1705        assert_eq!(delete_builder.spec.method, Method::DELETE);
1706        assert_eq!(delete_builder.spec.path, "/tests/v1/delete");
1707
1708        let patch_builder =
1709            OperationBuilder::<Missing, Missing, (), AuthNotSet>::patch("/tests/v1/patch");
1710        assert_eq!(patch_builder.spec.method, Method::PATCH);
1711        assert_eq!(patch_builder.spec.path, "/tests/v1/patch");
1712    }
1713
1714    #[test]
1715    fn normalize_to_axum_path_should_normalize() {
1716        // Axum 0.8+ uses {param} syntax, same as OpenAPI
1717        assert_eq!(
1718            normalize_to_axum_path("/tests/v1/users/{id}"),
1719            "/tests/v1/users/{id}"
1720        );
1721        assert_eq!(
1722            normalize_to_axum_path("/tests/v1/projects/{project_id}/items/{item_id}"),
1723            "/tests/v1/projects/{project_id}/items/{item_id}"
1724        );
1725        assert_eq!(
1726            normalize_to_axum_path("/tests/v1/simple"),
1727            "/tests/v1/simple"
1728        );
1729        assert_eq!(
1730            normalize_to_axum_path("/tests/v1/users/{id}/edit"),
1731            "/tests/v1/users/{id}/edit"
1732        );
1733    }
1734
1735    #[test]
1736    fn axum_to_openapi_path_should_convert() {
1737        // Regular parameters stay the same
1738        assert_eq!(
1739            axum_to_openapi_path("/tests/v1/users/{id}"),
1740            "/tests/v1/users/{id}"
1741        );
1742        assert_eq!(
1743            axum_to_openapi_path("/tests/v1/projects/{project_id}/items/{item_id}"),
1744            "/tests/v1/projects/{project_id}/items/{item_id}"
1745        );
1746        assert_eq!(axum_to_openapi_path("/tests/v1/simple"), "/tests/v1/simple");
1747        // Wildcards: Axum uses {*path}, OpenAPI uses {path}
1748        assert_eq!(
1749            axum_to_openapi_path("/tests/v1/static/{*path}"),
1750            "/tests/v1/static/{path}"
1751        );
1752        assert_eq!(
1753            axum_to_openapi_path("/tests/v1/files/{*filepath}"),
1754            "/tests/v1/files/{filepath}"
1755        );
1756    }
1757
1758    #[test]
1759    fn path_normalization_in_constructors() {
1760        // Test that paths are kept as-is (Axum 0.8+ uses same {param} syntax)
1761        let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/users/{id}");
1762        assert_eq!(builder.spec.path, "/tests/v1/users/{id}");
1763
1764        let builder = OperationBuilder::<Missing, Missing, ()>::post(
1765            "/tests/v1/projects/{project_id}/items/{item_id}",
1766        );
1767        assert_eq!(
1768            builder.spec.path,
1769            "/tests/v1/projects/{project_id}/items/{item_id}"
1770        );
1771
1772        // Simple paths remain unchanged
1773        let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/simple");
1774        assert_eq!(builder.spec.path, "/tests/v1/simple");
1775    }
1776
1777    #[test]
1778    fn standard_errors() {
1779        let registry = MockRegistry::new();
1780        let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1781            .public()
1782            .handler(test_handler)
1783            .json_response(http::StatusCode::OK, "Success")
1784            .standard_errors(&registry);
1785
1786        // Should have 1 success response + 8 standard error responses
1787        assert_eq!(builder.spec.responses.len(), 9);
1788
1789        // Check that all standard error status codes are present
1790        let statuses: Vec<u16> = builder.spec.responses.iter().map(|r| r.status).collect();
1791        assert!(statuses.contains(&200)); // success response
1792        assert!(statuses.contains(&400));
1793        assert!(statuses.contains(&401));
1794        assert!(statuses.contains(&403));
1795        assert!(statuses.contains(&404));
1796        assert!(statuses.contains(&409));
1797        assert!(statuses.contains(&422));
1798        assert!(statuses.contains(&429));
1799        assert!(statuses.contains(&500));
1800
1801        // All error responses should use Problem content type
1802        let error_responses: Vec<_> = builder
1803            .spec
1804            .responses
1805            .iter()
1806            .filter(|r| r.status >= 400)
1807            .collect();
1808
1809        for resp in error_responses {
1810            assert_eq!(
1811                resp.content_type,
1812                crate::api::problem::APPLICATION_PROBLEM_JSON
1813            );
1814            assert!(resp.schema_name.is_some());
1815        }
1816    }
1817
1818    #[test]
1819    fn authenticated() {
1820        let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1821            .authenticated()
1822            .handler(test_handler)
1823            .json_response(http::StatusCode::OK, "Success");
1824
1825        assert!(builder.spec.authenticated);
1826        assert!(!builder.spec.is_public);
1827    }
1828
1829    #[test]
1830    fn require_license_features_none() {
1831        let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1832            .authenticated()
1833            .require_license_features::<TestLicenseFeatures>([])
1834            .handler(|| async {})
1835            .json_response(http::StatusCode::OK, "OK");
1836
1837        assert!(builder.spec.license_requirement.is_none());
1838    }
1839
1840    #[test]
1841    fn no_license_required_transitions_and_allows_register() {
1842        let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1843            .authenticated()
1844            .no_license_required()
1845            .handler(|| async {})
1846            .json_response(http::StatusCode::OK, "OK");
1847
1848        assert!(builder.spec.license_requirement.is_none());
1849        assert!(!builder.spec.is_public);
1850    }
1851
1852    #[test]
1853    fn require_license_features_one() {
1854        let feature = TestLicenseFeatures::FeatureA;
1855
1856        let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1857            .authenticated()
1858            .require_license_features([&feature])
1859            .handler(|| async {})
1860            .json_response(http::StatusCode::OK, "OK");
1861
1862        let license_req = builder
1863            .spec
1864            .license_requirement
1865            .as_ref()
1866            .expect("Should have license requirement");
1867        assert_eq!(license_req.license_names, vec!["feature_a".to_owned()]);
1868    }
1869
1870    #[test]
1871    fn require_license_features_many() {
1872        let feature_a = TestLicenseFeatures::FeatureA;
1873        let feature_b = TestLicenseFeatures::FeatureB;
1874
1875        let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1876            .authenticated()
1877            .require_license_features([&feature_a, &feature_b])
1878            .handler(|| async {})
1879            .json_response(http::StatusCode::OK, "OK");
1880
1881        let license_req = builder
1882            .spec
1883            .license_requirement
1884            .as_ref()
1885            .expect("Should have license requirement");
1886        assert_eq!(
1887            license_req.license_names,
1888            vec!["feature_a".to_owned(), "feature_b".to_owned()]
1889        );
1890    }
1891
1892    #[tokio::test]
1893    async fn public_does_not_require_license_features_and_can_register() {
1894        let registry = MockRegistry::new();
1895        let router = Router::new();
1896
1897        let _router = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1898            .public()
1899            .handler(test_handler)
1900            .json_response(http::StatusCode::OK, "Success")
1901            .register(router, &registry);
1902
1903        let ops = registry.operations.lock().unwrap();
1904        assert_eq!(ops.len(), 1);
1905        assert!(ops[0].license_requirement.is_none());
1906    }
1907
1908    #[test]
1909    fn with_422_validation_error() {
1910        let registry = MockRegistry::new();
1911        let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
1912            .public()
1913            .handler(test_handler)
1914            .json_response(http::StatusCode::CREATED, "Created")
1915            .with_422_validation_error(&registry);
1916
1917        // Should have success response + validation error response
1918        assert_eq!(builder.spec.responses.len(), 2);
1919
1920        let validation_response = builder
1921            .spec
1922            .responses
1923            .iter()
1924            .find(|r| r.status == 422)
1925            .expect("Should have 422 response");
1926
1927        assert_eq!(validation_response.description, "Validation Error");
1928        assert_eq!(
1929            validation_response.content_type,
1930            crate::api::problem::APPLICATION_PROBLEM_JSON
1931        );
1932        assert!(validation_response.schema_name.is_some());
1933    }
1934
1935    #[test]
1936    fn allow_content_types_with_existing_request_body() {
1937        let registry = MockRegistry::new();
1938        let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
1939            .json_request::<SampleDtoRequest>(&registry, "Test request")
1940            .allow_content_types(&["application/json", "application/xml"])
1941            .public()
1942            .handler(test_handler)
1943            .json_response(http::StatusCode::OK, "Success");
1944
1945        // allowed_content_types should be on OperationSpec, not RequestBodySpec
1946        assert!(builder.spec.request_body.is_some());
1947        assert!(builder.spec.allowed_request_content_types.is_some());
1948        let allowed = builder.spec.allowed_request_content_types.as_ref().unwrap();
1949        assert_eq!(allowed.len(), 2);
1950        assert!(allowed.contains(&"application/json"));
1951        assert!(allowed.contains(&"application/xml"));
1952    }
1953
1954    #[test]
1955    fn allow_content_types_without_existing_request_body() {
1956        let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
1957            .allow_content_types(&["multipart/form-data"])
1958            .public()
1959            .handler(test_handler)
1960            .json_response(http::StatusCode::OK, "Success");
1961
1962        // Should NOT create synthetic request body, only set allowed_request_content_types
1963        assert!(builder.spec.request_body.is_none());
1964        assert!(builder.spec.allowed_request_content_types.is_some());
1965        let allowed = builder.spec.allowed_request_content_types.as_ref().unwrap();
1966        assert_eq!(allowed.len(), 1);
1967        assert!(allowed.contains(&"multipart/form-data"));
1968    }
1969
1970    #[test]
1971    fn allow_content_types_can_be_chained() {
1972        let registry = MockRegistry::new();
1973        let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
1974            .operation_id("test.post")
1975            .summary("Test endpoint")
1976            .json_request::<SampleDtoRequest>(&registry, "Test request")
1977            .allow_content_types(&["application/json"])
1978            .public()
1979            .handler(test_handler)
1980            .json_response(http::StatusCode::OK, "Success")
1981            .problem_response(
1982                &registry,
1983                http::StatusCode::UNSUPPORTED_MEDIA_TYPE,
1984                "Unsupported Media Type",
1985            );
1986
1987        assert_eq!(builder.spec.operation_id, Some("test.post".to_owned()));
1988        assert!(builder.spec.request_body.is_some());
1989        assert!(builder.spec.allowed_request_content_types.is_some());
1990        assert_eq!(builder.spec.responses.len(), 2);
1991    }
1992
1993    #[test]
1994    fn multipart_file_request() {
1995        let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/upload")
1996            .operation_id("test.upload")
1997            .summary("Upload file")
1998            .multipart_file_request("file", Some("Upload a file"))
1999            .public()
2000            .handler(test_handler)
2001            .json_response(http::StatusCode::OK, "Success");
2002
2003        // Should set request body with multipart/form-data
2004        assert!(builder.spec.request_body.is_some());
2005        let rb = builder.spec.request_body.as_ref().unwrap();
2006        assert_eq!(rb.content_type, "multipart/form-data");
2007        assert!(rb.description.is_some());
2008        assert!(rb.description.as_ref().unwrap().contains("file"));
2009        assert!(rb.required);
2010
2011        // Should use MultipartFile schema variant
2012        assert_eq!(
2013            rb.schema,
2014            RequestBodySchema::MultipartFile {
2015                field_name: "file".to_owned()
2016            }
2017        );
2018
2019        // Should also set allowed_request_content_types
2020        assert!(builder.spec.allowed_request_content_types.is_some());
2021        let allowed = builder.spec.allowed_request_content_types.as_ref().unwrap();
2022        assert_eq!(allowed.len(), 1);
2023        assert!(allowed.contains(&"multipart/form-data"));
2024    }
2025
2026    #[test]
2027    fn multipart_file_request_without_description() {
2028        let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/upload")
2029            .multipart_file_request("file", None)
2030            .public()
2031            .handler(test_handler)
2032            .json_response(http::StatusCode::OK, "Success");
2033
2034        assert!(builder.spec.request_body.is_some());
2035        let rb = builder.spec.request_body.as_ref().unwrap();
2036        assert_eq!(rb.content_type, "multipart/form-data");
2037        assert!(rb.description.is_none());
2038        assert_eq!(
2039            rb.schema,
2040            RequestBodySchema::MultipartFile {
2041                field_name: "file".to_owned()
2042            }
2043        );
2044    }
2045
2046    #[test]
2047    fn octet_stream_request() {
2048        let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/upload")
2049            .operation_id("test.upload")
2050            .summary("Upload raw file")
2051            .octet_stream_request(Some("Raw file bytes"))
2052            .public()
2053            .handler(test_handler)
2054            .json_response(http::StatusCode::OK, "Success");
2055
2056        // Should set request body with application/octet-stream
2057        assert!(builder.spec.request_body.is_some());
2058        let rb = builder.spec.request_body.as_ref().unwrap();
2059        assert_eq!(rb.content_type, "application/octet-stream");
2060        assert_eq!(rb.description, Some("Raw file bytes".to_owned()));
2061        assert!(rb.required);
2062
2063        // Should use Binary schema variant
2064        assert_eq!(rb.schema, RequestBodySchema::Binary);
2065
2066        // Should also set allowed_request_content_types
2067        assert!(builder.spec.allowed_request_content_types.is_some());
2068        let allowed = builder.spec.allowed_request_content_types.as_ref().unwrap();
2069        assert_eq!(allowed.len(), 1);
2070        assert!(allowed.contains(&"application/octet-stream"));
2071    }
2072
2073    #[test]
2074    fn octet_stream_request_without_description() {
2075        let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/upload")
2076            .octet_stream_request(None)
2077            .public()
2078            .handler(test_handler)
2079            .json_response(http::StatusCode::OK, "Success");
2080
2081        assert!(builder.spec.request_body.is_some());
2082        let rb = builder.spec.request_body.as_ref().unwrap();
2083        assert_eq!(rb.content_type, "application/octet-stream");
2084        assert!(rb.description.is_none());
2085        assert_eq!(rb.schema, RequestBodySchema::Binary);
2086    }
2087
2088    #[test]
2089    fn json_request_uses_ref_schema() {
2090        let registry = MockRegistry::new();
2091        let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
2092            .json_request::<SampleDtoRequest>(&registry, "Test request body")
2093            .public()
2094            .handler(test_handler)
2095            .json_response(http::StatusCode::OK, "Success");
2096
2097        assert!(builder.spec.request_body.is_some());
2098        let rb = builder.spec.request_body.as_ref().unwrap();
2099        assert_eq!(rb.content_type, "application/json");
2100
2101        // Should use Ref schema variant with the registered schema name
2102        match &rb.schema {
2103            RequestBodySchema::Ref { schema_name } => {
2104                assert!(!schema_name.is_empty());
2105            }
2106            _ => panic!("Expected RequestBodySchema::Ref for JSON request"),
2107        }
2108    }
2109
2110    #[test]
2111    fn response_content_types_must_not_contain_parameters() {
2112        // This test ensures OpenAPI correctness: media type keys cannot include
2113        // parameters like "; charset=utf-8"
2114        let registry = MockRegistry::new();
2115        let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
2116            .operation_id("test.content_type_purity")
2117            .summary("Test response content types")
2118            .json_request::<SampleDtoRequest>(&registry, "Test")
2119            .public()
2120            .handler(test_handler)
2121            .text_response(http::StatusCode::OK, "Text", "text/plain")
2122            .text_response(http::StatusCode::OK, "Markdown", "text/markdown")
2123            .html_response(http::StatusCode::OK, "HTML")
2124            .json_response(http::StatusCode::OK, "JSON")
2125            .problem_response(&registry, http::StatusCode::BAD_REQUEST, "Error");
2126
2127        // Verify no response content_type contains semicolon (parameter separator)
2128        for response in &builder.spec.responses {
2129            assert!(
2130                !response.content_type.contains(';'),
2131                "Response content_type '{}' must not contain parameters. \
2132                 Use pure media type without charset or other parameters. \
2133                 OpenAPI media type keys cannot include parameters.",
2134                response.content_type
2135            );
2136        }
2137    }
2138}