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