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