1use 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#[must_use]
39pub fn normalize_to_axum_path(path: &str) -> String {
40 path.to_owned()
45}
46
47#[must_use]
59pub fn axum_to_openapi_path(path: &str) -> String {
60 path.replace("{*", "{")
63}
64
65pub const CORE_GLOBAL_BASE_LICENSE_FEATURE: &str =
67 gts_id!("cf.core.lic.feat.v1~cf.core.global.base.v1");
68
69pub mod state {
71 #[derive(Debug, Clone, Copy)]
73 pub struct Missing;
74
75 #[derive(Debug, Clone, Copy)]
77 pub struct Present;
78
79 #[derive(Debug, Clone, Copy)]
81 pub struct AuthNotSet;
82
83 #[derive(Debug, Clone, Copy)]
85 pub struct AuthSet;
86
87 #[derive(Debug, Clone, Copy)]
89 pub struct LicenseNotSet;
90
91 #[derive(Debug, Clone, Copy)]
93 pub struct LicenseSet;
94}
95
96mod 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
109pub 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#[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, pub array: bool,
151}
152
153impl ParamSpec {
154 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#[derive(Clone, Debug, PartialEq, Eq)]
187pub enum RequestBodySchema {
188 Ref { schema_name: String },
190 MultipartFile { field_name: String },
192 Binary,
195 InlineObject,
197}
198
199#[derive(Clone, Debug)]
201pub struct RequestBodySpec {
202 pub content_type: &'static str,
203 pub description: Option<String>,
204 pub schema: RequestBodySchema,
206 pub required: bool,
208}
209
210#[derive(Clone, Debug, PartialEq, Eq)]
220pub enum ResponseSchema {
221 Ref { schema_name: String },
223 Array { items_schema_name: String },
225}
226
227impl ResponseSchema {
228 #[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#[derive(Clone, Debug)]
241pub struct ResponseSpec {
242 pub status: u16,
243 pub content_type: &'static str,
244 pub description: String,
245 pub schema: Option<ResponseSchema>,
247}
248
249impl ResponseSpec {
250 #[must_use]
254 pub fn schema_name(&self) -> Option<&str> {
255 self.schema.as_ref().map(ResponseSchema::schema_name)
256 }
257}
258
259#[derive(Clone, Debug)]
261pub struct LicenseReqSpec {
262 pub license_names: Vec<String>,
263}
264
265#[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 pub handler_id: String,
279 pub authenticated: bool,
285 pub exposed: bool,
291 pub rate_limit: Option<RateLimitSpec>,
293 pub allowed_request_content_types: Option<Vec<&'static str>>,
299 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#[derive(Clone, Debug, Default)]
320pub struct RateLimitSpec {
321 pub rps: u32,
323 pub burst: u32,
325 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
336pub trait OperationBuilderODataExt<S, H, R> {
338 #[must_use]
340 fn with_odata_filter<T>(self) -> Self
341 where
342 T: toolkit_odata::filter::FilterField;
343
344 #[must_use]
346 fn with_odata_select(self) -> Self;
347
348 #[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 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
459pub use crate::api::openapi_registry::{OpenApiRegistry, ensure_schema};
461
462#[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>, _auth_state: PhantomData<A>,
484 _license_state: PhantomData<L>,
485}
486
487impl<S> OperationBuilder<Missing, Missing, S, AuthNotSet> {
491 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: (), _has_handler: PhantomData,
521 _has_response: PhantomData,
522 _state: PhantomData,
523 _auth_state: PhantomData,
524 _license_state: PhantomData,
525 }
526 }
527
528 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 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 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 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 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
559impl<H, R, S, A, L> OperationBuilder<H, R, S, A, L>
563where
564 H: HandlerSlot<S>,
565 A: AuthState,
566 L: LicenseState,
567{
568 pub fn spec(&self) -> &OperationSpec {
570 &self.spec
571 }
572
573 pub fn operation_id(mut self, id: impl Into<String>) -> Self {
575 self.spec.operation_id = Some(id.into());
576 self
577 }
578
579 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 pub fn summary(mut self, text: impl Into<String>) -> Self {
592 self.spec.summary = Some(text.into());
593 self
594 }
595
596 pub fn description(mut self, text: impl Into<String>) -> Self {
598 self.spec.description = Some(text.into());
599 self
600 }
601
602 pub fn tag(mut self, tag: impl Into<String>) -> Self {
604 self.spec.tags.push(tag.into());
605 self
606 }
607
608 pub fn param(mut self, param: ParamSpec) -> Self {
610 self.spec.params.push(param);
611 self
612 }
613
614 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 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 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 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 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 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 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 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 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 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 pub fn multipart_file_request(mut self, field_name: &str, description: Option<&str>) -> Self {
819 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 self.spec.allowed_request_content_types = Some(vec!["multipart/form-data"]);
832
833 self
834 }
835
836 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 self.spec.allowed_request_content_types = Some(vec!["application/octet-stream"]);
889
890 self
891 }
892
893 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 pub fn exposed(mut self) -> Self {
935 self.spec.exposed = true;
936 self
937 }
938}
939
940impl<H, R, S> OperationBuilder<H, R, S, AuthSet, LicenseNotSet>
942where
943 H: HandlerSlot<S>,
944{
945 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 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
1003impl<H, R, S, L> OperationBuilder<H, R, S, AuthNotSet, L>
1007where
1008 H: HandlerSlot<S>,
1009 L: LicenseState,
1010{
1011 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 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(
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
1135impl<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 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, _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 pub fn method_router(self, mr: MethodRouter<S>) -> OperationBuilder<Present, R, S, A, L> {
1175 OperationBuilder {
1176 spec: self.spec,
1177 method_router: mr, _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
1187impl<H, S, A, L> OperationBuilder<H, Missing, S, A, L>
1191where
1192 H: HandlerSlot<S>,
1193 A: AuthState,
1194 L: LicenseState,
1195{
1196 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 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 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 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 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 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 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 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 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 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
1442impl<H, S, A, L> OperationBuilder<H, Present, S, A, L>
1446where
1447 H: HandlerSlot<S>,
1448 A: AuthState,
1449 L: LicenseState,
1450{
1451 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 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 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 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 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 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 pub fn problem_response(
1569 mut self,
1570 registry: &dyn OpenApiRegistry,
1571 status: http::StatusCode,
1572 description: impl Into<String>,
1573 ) -> Self {
1574 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 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 pub fn standard_errors(mut self, registry: &dyn OpenApiRegistry) -> Self {
1648 use http::StatusCode;
1649 let problem_name = ensure_schema::<toolkit_canonical_errors::Problem>(registry);
1651
1652 let standard_errors = [
1653 (StatusCode::BAD_REQUEST, "Bad Request"),
1654 (StatusCode::UNAUTHORIZED, "Unauthorized"),
1655 (StatusCode::FORBIDDEN, "Forbidden"),
1656 (StatusCode::NOT_FOUND, "Not Found"),
1657 (StatusCode::CONFLICT, "Conflict"),
1658 (StatusCode::TOO_MANY_REQUESTS, "Too Many Requests"),
1659 (StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error"),
1660 ];
1661
1662 for (status, description) in standard_errors {
1663 self.spec.responses.push(ResponseSpec {
1664 status: status.as_u16(),
1665 content_type: problem::APPLICATION_PROBLEM_JSON,
1666 description: description.to_owned(),
1667 schema: Some(ResponseSchema::Ref {
1668 schema_name: problem_name.clone(),
1669 }),
1670 });
1671 }
1672
1673 self
1674 }
1675
1676 pub fn with_400_validation_error(mut self, registry: &dyn OpenApiRegistry) -> Self {
1713 let problem_name = ensure_schema::<toolkit_canonical_errors::Problem>(registry);
1714
1715 self.spec.responses.push(ResponseSpec {
1716 status: http::StatusCode::BAD_REQUEST.as_u16(),
1717 content_type: problem::APPLICATION_PROBLEM_JSON,
1718 description: "Validation Error".to_owned(),
1719 schema: Some(ResponseSchema::Ref {
1720 schema_name: problem_name,
1721 }),
1722 });
1723
1724 self
1725 }
1726
1727 pub fn error_400(self, registry: &dyn OpenApiRegistry) -> Self {
1731 self.problem_response(registry, http::StatusCode::BAD_REQUEST, "Bad Request")
1732 }
1733
1734 pub fn error_401(self, registry: &dyn OpenApiRegistry) -> Self {
1738 self.problem_response(registry, http::StatusCode::UNAUTHORIZED, "Unauthorized")
1739 }
1740
1741 pub fn error_403(self, registry: &dyn OpenApiRegistry) -> Self {
1745 self.problem_response(registry, http::StatusCode::FORBIDDEN, "Forbidden")
1746 }
1747
1748 pub fn error_404(self, registry: &dyn OpenApiRegistry) -> Self {
1752 self.problem_response(registry, http::StatusCode::NOT_FOUND, "Not Found")
1753 }
1754
1755 pub fn error_409(self, registry: &dyn OpenApiRegistry) -> Self {
1759 self.problem_response(registry, http::StatusCode::CONFLICT, "Conflict")
1760 }
1761
1762 pub fn error_415(self, registry: &dyn OpenApiRegistry) -> Self {
1766 self.problem_response(
1767 registry,
1768 http::StatusCode::UNSUPPORTED_MEDIA_TYPE,
1769 "Unsupported Media Type",
1770 )
1771 }
1772
1773 pub fn error_422(self, registry: &dyn OpenApiRegistry) -> Self {
1777 self.problem_response(
1778 registry,
1779 http::StatusCode::UNPROCESSABLE_ENTITY,
1780 "Unprocessable Entity",
1781 )
1782 }
1783
1784 pub fn error_429(self, registry: &dyn OpenApiRegistry) -> Self {
1788 self.problem_response(
1789 registry,
1790 http::StatusCode::TOO_MANY_REQUESTS,
1791 "Too Many Requests",
1792 )
1793 }
1794
1795 pub fn error_500(self, registry: &dyn OpenApiRegistry) -> Self {
1799 self.problem_response(
1800 registry,
1801 http::StatusCode::INTERNAL_SERVER_ERROR,
1802 "Internal Server Error",
1803 )
1804 }
1805
1806 pub fn error_502(self, registry: &dyn OpenApiRegistry) -> Self {
1810 self.problem_response(registry, http::StatusCode::BAD_GATEWAY, "Bad Gateway")
1811 }
1812
1813 pub fn error_503(self, registry: &dyn OpenApiRegistry) -> Self {
1817 self.problem_response(
1818 registry,
1819 http::StatusCode::SERVICE_UNAVAILABLE,
1820 "Service Unavailable",
1821 )
1822 }
1823
1824 pub fn error_504(self, registry: &dyn OpenApiRegistry) -> Self {
1828 self.problem_response(
1829 registry,
1830 http::StatusCode::GATEWAY_TIMEOUT,
1831 "Gateway Timeout",
1832 )
1833 }
1834}
1835
1836impl<S> OperationBuilder<Present, Present, S, AuthSet, LicenseSet>
1840where
1841 S: Clone + Send + Sync + 'static,
1842{
1843 pub fn register(self, router: Router<S>, openapi: &dyn OpenApiRegistry) -> Router<S> {
1852 openapi.register_operation(&self.spec);
1855
1856 router.route(&self.spec.path, self.method_router)
1858 }
1859}
1860
1861#[cfg(test)]
1865#[cfg_attr(coverage_nightly, coverage(off))]
1866mod tests {
1867 use super::*;
1868 use axum::Json;
1869
1870 struct MockRegistry {
1872 operations: std::sync::Mutex<Vec<OperationSpec>>,
1873 schemas: std::sync::Mutex<Vec<String>>,
1874 }
1875
1876 impl MockRegistry {
1877 fn new() -> Self {
1878 Self {
1879 operations: std::sync::Mutex::new(Vec::new()),
1880 schemas: std::sync::Mutex::new(Vec::new()),
1881 }
1882 }
1883 }
1884
1885 enum TestLicenseFeatures {
1886 FeatureA,
1887 FeatureB,
1888 }
1889 impl AsRef<str> for TestLicenseFeatures {
1890 fn as_ref(&self) -> &str {
1891 match self {
1892 TestLicenseFeatures::FeatureA => "feature_a",
1893 TestLicenseFeatures::FeatureB => "feature_b",
1894 }
1895 }
1896 }
1897 impl LicenseFeature for TestLicenseFeatures {}
1898
1899 impl OpenApiRegistry for MockRegistry {
1900 fn register_operation(&self, spec: &OperationSpec) {
1901 if let Ok(mut ops) = self.operations.lock() {
1902 ops.push(spec.clone());
1903 }
1904 }
1905
1906 fn ensure_schema_raw(
1907 &self,
1908 name: &str,
1909 _schemas: Vec<(
1910 String,
1911 utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>,
1912 )>,
1913 ) -> String {
1914 let name = name.to_owned();
1915 if let Ok(mut s) = self.schemas.lock() {
1916 s.push(name.clone());
1917 }
1918 name
1919 }
1920
1921 fn as_any(&self) -> &dyn std::any::Any {
1922 self
1923 }
1924 }
1925
1926 async fn test_handler() -> Json<serde_json::Value> {
1927 Json(serde_json::json!({"status": "ok"}))
1928 }
1929
1930 #[toolkit_macros::api_dto(request)]
1931 struct SampleDtoRequest;
1932
1933 #[toolkit_macros::api_dto(response)]
1934 struct SampleDtoResponse;
1935
1936 #[test]
1937 fn builder_descriptive_methods() {
1938 let builder = OperationBuilder::<Missing, Missing, (), AuthNotSet>::get("/tests/v1/test")
1939 .operation_id("test.get")
1940 .summary("Test endpoint")
1941 .description("A test endpoint for validation")
1942 .tag("test")
1943 .path_param("id", "Test ID");
1944
1945 assert_eq!(builder.spec.method, Method::GET);
1946 assert_eq!(builder.spec.path, "/tests/v1/test");
1947 assert_eq!(builder.spec.operation_id, Some("test.get".to_owned()));
1948 assert_eq!(builder.spec.summary, Some("Test endpoint".to_owned()));
1949 assert_eq!(
1950 builder.spec.description,
1951 Some("A test endpoint for validation".to_owned())
1952 );
1953 assert_eq!(builder.spec.tags, vec!["test"]);
1954 assert_eq!(builder.spec.params.len(), 1);
1955 }
1956
1957 #[tokio::test]
1958 async fn builder_with_request_response_and_handler() {
1959 let registry = MockRegistry::new();
1960 let router = Router::new();
1961
1962 let _router = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
1963 .summary("Test endpoint")
1964 .json_request::<SampleDtoRequest>(®istry, "optional body") .anonymous()
1966 .handler(test_handler)
1967 .json_response_with_schema::<SampleDtoResponse>(
1968 ®istry,
1969 http::StatusCode::OK,
1970 "Success response",
1971 ) .register(router, ®istry);
1973
1974 let ops = registry.operations.lock().unwrap();
1976 assert_eq!(ops.len(), 1);
1977 let op = &ops[0];
1978 assert_eq!(op.method, Method::POST);
1979 assert_eq!(op.path, "/tests/v1/test");
1980 assert!(op.request_body.is_some());
1981 assert!(op.request_body.as_ref().unwrap().required);
1982 assert_eq!(op.responses.len(), 1);
1983 assert_eq!(op.responses[0].status, 200);
1984
1985 let schemas = registry.schemas.lock().unwrap();
1987 assert!(!schemas.is_empty());
1988 }
1989
1990 #[test]
1991 fn convenience_constructors() {
1992 let get_builder =
1993 OperationBuilder::<Missing, Missing, (), AuthNotSet>::get("/tests/v1/get");
1994 assert_eq!(get_builder.spec.method, Method::GET);
1995 assert_eq!(get_builder.spec.path, "/tests/v1/get");
1996
1997 let post_builder =
1998 OperationBuilder::<Missing, Missing, (), AuthNotSet>::post("/tests/v1/post");
1999 assert_eq!(post_builder.spec.method, Method::POST);
2000 assert_eq!(post_builder.spec.path, "/tests/v1/post");
2001
2002 let put_builder =
2003 OperationBuilder::<Missing, Missing, (), AuthNotSet>::put("/tests/v1/put");
2004 assert_eq!(put_builder.spec.method, Method::PUT);
2005 assert_eq!(put_builder.spec.path, "/tests/v1/put");
2006
2007 let delete_builder =
2008 OperationBuilder::<Missing, Missing, (), AuthNotSet>::delete("/tests/v1/delete");
2009 assert_eq!(delete_builder.spec.method, Method::DELETE);
2010 assert_eq!(delete_builder.spec.path, "/tests/v1/delete");
2011
2012 let patch_builder =
2013 OperationBuilder::<Missing, Missing, (), AuthNotSet>::patch("/tests/v1/patch");
2014 assert_eq!(patch_builder.spec.method, Method::PATCH);
2015 assert_eq!(patch_builder.spec.path, "/tests/v1/patch");
2016 }
2017
2018 #[test]
2019 fn normalize_to_axum_path_should_normalize() {
2020 assert_eq!(
2022 normalize_to_axum_path("/tests/v1/users/{id}"),
2023 "/tests/v1/users/{id}"
2024 );
2025 assert_eq!(
2026 normalize_to_axum_path("/tests/v1/projects/{project_id}/items/{item_id}"),
2027 "/tests/v1/projects/{project_id}/items/{item_id}"
2028 );
2029 assert_eq!(
2030 normalize_to_axum_path("/tests/v1/simple"),
2031 "/tests/v1/simple"
2032 );
2033 assert_eq!(
2034 normalize_to_axum_path("/tests/v1/users/{id}/edit"),
2035 "/tests/v1/users/{id}/edit"
2036 );
2037 }
2038
2039 #[test]
2040 fn axum_to_openapi_path_should_convert() {
2041 assert_eq!(
2043 axum_to_openapi_path("/tests/v1/users/{id}"),
2044 "/tests/v1/users/{id}"
2045 );
2046 assert_eq!(
2047 axum_to_openapi_path("/tests/v1/projects/{project_id}/items/{item_id}"),
2048 "/tests/v1/projects/{project_id}/items/{item_id}"
2049 );
2050 assert_eq!(axum_to_openapi_path("/tests/v1/simple"), "/tests/v1/simple");
2051 assert_eq!(
2053 axum_to_openapi_path("/tests/v1/static/{*path}"),
2054 "/tests/v1/static/{path}"
2055 );
2056 assert_eq!(
2057 axum_to_openapi_path("/tests/v1/files/{*filepath}"),
2058 "/tests/v1/files/{filepath}"
2059 );
2060 }
2061
2062 #[test]
2063 fn path_normalization_in_constructors() {
2064 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/users/{id}");
2066 assert_eq!(builder.spec.path, "/tests/v1/users/{id}");
2067
2068 let builder = OperationBuilder::<Missing, Missing, ()>::post(
2069 "/tests/v1/projects/{project_id}/items/{item_id}",
2070 );
2071 assert_eq!(
2072 builder.spec.path,
2073 "/tests/v1/projects/{project_id}/items/{item_id}"
2074 );
2075
2076 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/simple");
2078 assert_eq!(builder.spec.path, "/tests/v1/simple");
2079 }
2080
2081 #[test]
2082 fn standard_errors() {
2083 let registry = MockRegistry::new();
2084 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
2085 .anonymous()
2086 .handler(test_handler)
2087 .json_response(http::StatusCode::OK, "Success")
2088 .standard_errors(®istry);
2089
2090 assert_eq!(builder.spec.responses.len(), 8);
2093
2094 let statuses: Vec<u16> = builder.spec.responses.iter().map(|r| r.status).collect();
2096 assert!(statuses.contains(&200)); assert!(statuses.contains(&400));
2098 assert!(statuses.contains(&401));
2099 assert!(statuses.contains(&403));
2100 assert!(statuses.contains(&404));
2101 assert!(statuses.contains(&409));
2102 assert!(!statuses.contains(&422));
2103 assert!(statuses.contains(&429));
2104 assert!(statuses.contains(&500));
2105
2106 let error_responses: Vec<_> = builder
2108 .spec
2109 .responses
2110 .iter()
2111 .filter(|r| r.status >= 400)
2112 .collect();
2113
2114 for resp in error_responses {
2115 assert_eq!(
2116 resp.content_type,
2117 toolkit_canonical_errors::problem::APPLICATION_PROBLEM_JSON
2118 );
2119 assert!(resp.schema_name().is_some());
2120 }
2121 }
2122
2123 #[test]
2124 fn authenticated() {
2125 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
2126 .authenticated()
2127 .handler(test_handler)
2128 .json_response(http::StatusCode::OK, "Success");
2129
2130 assert!(builder.spec.authenticated);
2131 assert!(!builder.spec.exposed);
2132 }
2133
2134 #[test]
2135 fn anonymous_is_internal_by_default() {
2136 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
2137 .anonymous()
2138 .handler(test_handler)
2139 .json_response(http::StatusCode::OK, "Success");
2140
2141 assert!(!builder.spec.authenticated);
2142 assert!(!builder.spec.exposed);
2143 }
2144
2145 #[test]
2146 fn exposed_is_independent_of_auth() {
2147 let authed = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/a")
2150 .exposed()
2151 .authenticated()
2152 .handler(test_handler)
2153 .json_response(http::StatusCode::OK, "OK");
2154 assert!(authed.spec.authenticated);
2155 assert!(authed.spec.exposed);
2156
2157 let anon = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/b")
2158 .exposed()
2159 .anonymous()
2160 .handler(test_handler)
2161 .json_response(http::StatusCode::OK, "OK");
2162 assert!(!anon.spec.authenticated);
2163 assert!(anon.spec.exposed);
2164 }
2165
2166 #[test]
2167 #[allow(deprecated)]
2168 fn deprecated_public_maps_to_anonymous_and_exposed() {
2169 let op = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/ping")
2172 .public()
2173 .handler(test_handler)
2174 .json_response(http::StatusCode::OK, "OK");
2175 assert!(!op.spec.authenticated, "public route is anonymous");
2176 assert!(op.spec.exposed, "public route is edge-exposed");
2177 }
2178
2179 #[test]
2180 fn require_license_features_none() {
2181 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
2182 .authenticated()
2183 .require_license_features::<TestLicenseFeatures>([])
2184 .handler(|| async {})
2185 .json_response(http::StatusCode::OK, "OK");
2186
2187 assert!(builder.spec.license_requirement.is_none());
2188 }
2189
2190 #[test]
2191 fn no_license_required_transitions_and_allows_register() {
2192 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
2193 .authenticated()
2194 .no_license_required()
2195 .handler(|| async {})
2196 .json_response(http::StatusCode::OK, "OK");
2197
2198 assert!(builder.spec.license_requirement.is_none());
2199 assert!(!builder.spec.exposed);
2200 }
2201
2202 #[test]
2203 fn require_license_features_one() {
2204 let feature = TestLicenseFeatures::FeatureA;
2205
2206 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
2207 .authenticated()
2208 .require_license_features([&feature])
2209 .handler(|| async {})
2210 .json_response(http::StatusCode::OK, "OK");
2211
2212 let license_req = builder
2213 .spec
2214 .license_requirement
2215 .as_ref()
2216 .expect("Should have license requirement");
2217 assert_eq!(license_req.license_names, vec!["feature_a".to_owned()]);
2218 }
2219
2220 #[test]
2221 fn require_license_features_many() {
2222 let feature_a = TestLicenseFeatures::FeatureA;
2223 let feature_b = TestLicenseFeatures::FeatureB;
2224
2225 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
2226 .authenticated()
2227 .require_license_features([&feature_a, &feature_b])
2228 .handler(|| async {})
2229 .json_response(http::StatusCode::OK, "OK");
2230
2231 let license_req = builder
2232 .spec
2233 .license_requirement
2234 .as_ref()
2235 .expect("Should have license requirement");
2236 assert_eq!(
2237 license_req.license_names,
2238 vec!["feature_a".to_owned(), "feature_b".to_owned()]
2239 );
2240 }
2241
2242 #[tokio::test]
2243 async fn public_does_not_require_license_features_and_can_register() {
2244 let registry = MockRegistry::new();
2245 let router = Router::new();
2246
2247 let _router = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
2248 .anonymous()
2249 .handler(test_handler)
2250 .json_response(http::StatusCode::OK, "Success")
2251 .register(router, ®istry);
2252
2253 let ops = registry.operations.lock().unwrap();
2254 assert_eq!(ops.len(), 1);
2255 assert!(ops[0].license_requirement.is_none());
2256 }
2257
2258 #[test]
2259 fn with_400_validation_error() {
2260 let registry = MockRegistry::new();
2261 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
2262 .anonymous()
2263 .handler(test_handler)
2264 .json_response(http::StatusCode::CREATED, "Created")
2265 .with_400_validation_error(®istry);
2266
2267 assert_eq!(builder.spec.responses.len(), 2);
2269
2270 let validation_response = builder
2271 .spec
2272 .responses
2273 .iter()
2274 .find(|r| r.status == 400)
2275 .expect("Should have 400 response");
2276
2277 assert_eq!(validation_response.description, "Validation Error");
2278 assert_eq!(
2279 validation_response.content_type,
2280 toolkit_canonical_errors::problem::APPLICATION_PROBLEM_JSON
2281 );
2282 assert!(validation_response.schema_name().is_some());
2283 }
2284
2285 #[test]
2286 fn allow_content_types_with_existing_request_body() {
2287 let registry = MockRegistry::new();
2288 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
2289 .json_request::<SampleDtoRequest>(®istry, "Test request")
2290 .allow_content_types(&["application/json", "application/xml"])
2291 .anonymous()
2292 .handler(test_handler)
2293 .json_response(http::StatusCode::OK, "Success");
2294
2295 assert!(builder.spec.request_body.is_some());
2297 assert!(builder.spec.allowed_request_content_types.is_some());
2298 let allowed = builder.spec.allowed_request_content_types.as_ref().unwrap();
2299 assert_eq!(allowed.len(), 2);
2300 assert!(allowed.contains(&"application/json"));
2301 assert!(allowed.contains(&"application/xml"));
2302 }
2303
2304 #[test]
2305 fn allow_content_types_without_existing_request_body() {
2306 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
2307 .allow_content_types(&["multipart/form-data"])
2308 .anonymous()
2309 .handler(test_handler)
2310 .json_response(http::StatusCode::OK, "Success");
2311
2312 assert!(builder.spec.request_body.is_none());
2314 assert!(builder.spec.allowed_request_content_types.is_some());
2315 let allowed = builder.spec.allowed_request_content_types.as_ref().unwrap();
2316 assert_eq!(allowed.len(), 1);
2317 assert!(allowed.contains(&"multipart/form-data"));
2318 }
2319
2320 #[test]
2321 fn allow_content_types_can_be_chained() {
2322 let registry = MockRegistry::new();
2323 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
2324 .operation_id("test.post")
2325 .summary("Test endpoint")
2326 .json_request::<SampleDtoRequest>(®istry, "Test request")
2327 .allow_content_types(&["application/json"])
2328 .anonymous()
2329 .handler(test_handler)
2330 .json_response(http::StatusCode::OK, "Success")
2331 .problem_response(
2332 ®istry,
2333 http::StatusCode::UNSUPPORTED_MEDIA_TYPE,
2334 "Unsupported Media Type",
2335 );
2336
2337 assert_eq!(builder.spec.operation_id, Some("test.post".to_owned()));
2338 assert!(builder.spec.request_body.is_some());
2339 assert!(builder.spec.allowed_request_content_types.is_some());
2340 assert_eq!(builder.spec.responses.len(), 2);
2341 }
2342
2343 #[test]
2344 fn multipart_file_request() {
2345 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/upload")
2346 .operation_id("test.upload")
2347 .summary("Upload file")
2348 .multipart_file_request("file", Some("Upload a file"))
2349 .anonymous()
2350 .handler(test_handler)
2351 .json_response(http::StatusCode::OK, "Success");
2352
2353 assert!(builder.spec.request_body.is_some());
2355 let rb = builder.spec.request_body.as_ref().unwrap();
2356 assert_eq!(rb.content_type, "multipart/form-data");
2357 assert!(rb.description.is_some());
2358 assert!(rb.description.as_ref().unwrap().contains("file"));
2359 assert!(rb.required);
2360
2361 assert_eq!(
2363 rb.schema,
2364 RequestBodySchema::MultipartFile {
2365 field_name: "file".to_owned()
2366 }
2367 );
2368
2369 assert!(builder.spec.allowed_request_content_types.is_some());
2371 let allowed = builder.spec.allowed_request_content_types.as_ref().unwrap();
2372 assert_eq!(allowed.len(), 1);
2373 assert!(allowed.contains(&"multipart/form-data"));
2374 }
2375
2376 #[test]
2377 fn multipart_file_request_without_description() {
2378 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/upload")
2379 .multipart_file_request("file", None)
2380 .anonymous()
2381 .handler(test_handler)
2382 .json_response(http::StatusCode::OK, "Success");
2383
2384 assert!(builder.spec.request_body.is_some());
2385 let rb = builder.spec.request_body.as_ref().unwrap();
2386 assert_eq!(rb.content_type, "multipart/form-data");
2387 assert!(rb.description.is_none());
2388 assert_eq!(
2389 rb.schema,
2390 RequestBodySchema::MultipartFile {
2391 field_name: "file".to_owned()
2392 }
2393 );
2394 }
2395
2396 #[test]
2397 fn octet_stream_request() {
2398 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/upload")
2399 .operation_id("test.upload")
2400 .summary("Upload raw file")
2401 .octet_stream_request(Some("Raw file bytes"))
2402 .anonymous()
2403 .handler(test_handler)
2404 .json_response(http::StatusCode::OK, "Success");
2405
2406 assert!(builder.spec.request_body.is_some());
2408 let rb = builder.spec.request_body.as_ref().unwrap();
2409 assert_eq!(rb.content_type, "application/octet-stream");
2410 assert_eq!(rb.description, Some("Raw file bytes".to_owned()));
2411 assert!(rb.required);
2412
2413 assert_eq!(rb.schema, RequestBodySchema::Binary);
2415
2416 assert!(builder.spec.allowed_request_content_types.is_some());
2418 let allowed = builder.spec.allowed_request_content_types.as_ref().unwrap();
2419 assert_eq!(allowed.len(), 1);
2420 assert!(allowed.contains(&"application/octet-stream"));
2421 }
2422
2423 #[test]
2424 fn octet_stream_request_without_description() {
2425 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/upload")
2426 .octet_stream_request(None)
2427 .anonymous()
2428 .handler(test_handler)
2429 .json_response(http::StatusCode::OK, "Success");
2430
2431 assert!(builder.spec.request_body.is_some());
2432 let rb = builder.spec.request_body.as_ref().unwrap();
2433 assert_eq!(rb.content_type, "application/octet-stream");
2434 assert!(rb.description.is_none());
2435 assert_eq!(rb.schema, RequestBodySchema::Binary);
2436 }
2437
2438 #[test]
2439 fn json_request_uses_ref_schema() {
2440 let registry = MockRegistry::new();
2441 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
2442 .json_request::<SampleDtoRequest>(®istry, "Test request body")
2443 .anonymous()
2444 .handler(test_handler)
2445 .json_response(http::StatusCode::OK, "Success");
2446
2447 assert!(builder.spec.request_body.is_some());
2448 let rb = builder.spec.request_body.as_ref().unwrap();
2449 assert_eq!(rb.content_type, "application/json");
2450
2451 match &rb.schema {
2453 RequestBodySchema::Ref { schema_name } => {
2454 assert!(!schema_name.is_empty());
2455 }
2456 _ => panic!("Expected RequestBodySchema::Ref for JSON request"),
2457 }
2458 }
2459
2460 #[test]
2461 fn response_content_types_must_not_contain_parameters() {
2462 let registry = MockRegistry::new();
2465 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
2466 .operation_id("test.content_type_purity")
2467 .summary("Test response content types")
2468 .json_request::<SampleDtoRequest>(®istry, "Test")
2469 .anonymous()
2470 .handler(test_handler)
2471 .text_response(http::StatusCode::OK, "Text", "text/plain")
2472 .text_response(http::StatusCode::OK, "Markdown", "text/markdown")
2473 .html_response(http::StatusCode::OK, "HTML")
2474 .json_response(http::StatusCode::OK, "JSON")
2475 .problem_response(®istry, http::StatusCode::BAD_REQUEST, "Error");
2476
2477 for response in &builder.spec.responses {
2479 assert!(
2480 !response.content_type.contains(';'),
2481 "Response content_type '{}' must not contain parameters. \
2482 Use pure media type without charset or other parameters. \
2483 OpenAPI media type keys cannot include parameters.",
2484 response.content_type
2485 );
2486 }
2487 }
2488}