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