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