Skip to main content

toolkit/api/
operation_builder.rs

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