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