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