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, }
147
148pub trait LicenseFeature: AsRef<str> {}
149
150impl<T: LicenseFeature + ?Sized> LicenseFeature for &T {}
151
152#[derive(Clone, Debug, PartialEq, Eq)]
153pub enum ParamLocation {
154 Path,
155 Query,
156 Header,
157 Cookie,
158}
159
160#[derive(Clone, Debug, PartialEq, Eq)]
162pub enum RequestBodySchema {
163 Ref { schema_name: String },
165 MultipartFile { field_name: String },
167 Binary,
170 InlineObject,
172}
173
174#[derive(Clone, Debug)]
176pub struct RequestBodySpec {
177 pub content_type: &'static str,
178 pub description: Option<String>,
179 pub schema: RequestBodySchema,
181 pub required: bool,
183}
184
185#[derive(Clone, Debug, PartialEq, Eq)]
195pub enum ResponseSchema {
196 Ref { schema_name: String },
198 Array { items_schema_name: String },
200}
201
202impl ResponseSchema {
203 #[must_use]
206 pub fn schema_name(&self) -> &str {
207 match self {
208 Self::Ref { schema_name } => schema_name,
209 Self::Array { items_schema_name } => items_schema_name,
210 }
211 }
212}
213
214#[derive(Clone, Debug)]
216pub struct ResponseSpec {
217 pub status: u16,
218 pub content_type: &'static str,
219 pub description: String,
220 pub schema: Option<ResponseSchema>,
222}
223
224impl ResponseSpec {
225 #[must_use]
229 pub fn schema_name(&self) -> Option<&str> {
230 self.schema.as_ref().map(ResponseSchema::schema_name)
231 }
232}
233
234#[derive(Clone, Debug)]
236pub struct LicenseReqSpec {
237 pub license_names: Vec<String>,
238}
239
240#[derive(Clone, Debug)]
242pub struct OperationSpec {
243 pub method: Method,
244 pub path: String,
245 pub operation_id: Option<String>,
246 pub summary: Option<String>,
247 pub description: Option<String>,
248 pub tags: Vec<String>,
249 pub params: Vec<ParamSpec>,
250 pub request_body: Option<RequestBodySpec>,
251 pub responses: Vec<ResponseSpec>,
252 pub handler_id: String,
254 pub authenticated: bool,
257 pub is_public: bool,
259 pub rate_limit: Option<RateLimitSpec>,
261 pub allowed_request_content_types: Option<Vec<&'static str>>,
267 pub vendor_extensions: VendorExtensions,
269 pub license_requirement: Option<LicenseReqSpec>,
270}
271
272#[derive(Clone, Debug, Default, Deserialize, Serialize)]
273pub struct VendorExtensions {
274 #[serde(rename = "x-odata-filter", skip_serializing_if = "Option::is_none")]
275 pub x_odata_filter: Option<ODataPagination<BTreeMap<String, Vec<String>>>>,
276 #[serde(rename = "x-odata-orderby", skip_serializing_if = "Option::is_none")]
277 pub x_odata_orderby: Option<ODataPagination<Vec<String>>>,
278}
279
280#[derive(Clone, Debug, Default, Deserialize, Serialize)]
281pub struct ODataPagination<T> {
282 #[serde(rename = "allowedFields")]
283 pub allowed_fields: T,
284}
285
286#[derive(Clone, Debug, Default)]
288pub struct RateLimitSpec {
289 pub rps: u32,
291 pub burst: u32,
293 pub in_flight: u32,
295}
296
297#[derive(Clone, Debug, Deserialize, Serialize, Default)]
298#[serde(rename_all = "camelCase")]
299pub struct XPagination {
300 pub filter_fields: BTreeMap<String, Vec<String>>,
301 pub order_by: Vec<String>,
302}
303
304pub trait OperationBuilderODataExt<S, H, R> {
306 #[must_use]
308 fn with_odata_filter<T>(self) -> Self
309 where
310 T: toolkit_odata::filter::FilterField;
311
312 #[must_use]
314 fn with_odata_select(self) -> Self;
315
316 #[must_use]
318 fn with_odata_orderby<T>(self) -> Self
319 where
320 T: toolkit_odata::filter::FilterField;
321}
322
323impl<S, H, R, A, L> OperationBuilderODataExt<S, H, R> for OperationBuilder<H, R, S, A, L>
324where
325 H: HandlerSlot<S>,
326 A: AuthState,
327 L: LicenseState,
328{
329 fn with_odata_filter<T>(mut self) -> Self
330 where
331 T: toolkit_odata::filter::FilterField,
332 {
333 use std::fmt::Write as _;
334 use toolkit_odata::filter::FieldKind;
335
336 let mut filter = self
337 .spec
338 .vendor_extensions
339 .x_odata_filter
340 .unwrap_or_default();
341
342 let mut description = "OData v4 filter expression".to_owned();
343 for field in T::FIELDS {
344 let name = field.name().to_owned();
345 let kind = field.kind();
346
347 let ops: Vec<String> = match kind {
348 FieldKind::String => vec!["eq", "ne", "contains", "startswith", "endswith", "in"],
349 FieldKind::Uuid => vec!["eq", "ne", "in"],
350 FieldKind::Bool => vec!["eq", "ne"],
351 FieldKind::I64
352 | FieldKind::F64
353 | FieldKind::Decimal
354 | FieldKind::DateTimeUtc
355 | FieldKind::Date
356 | FieldKind::Time => {
357 vec!["eq", "ne", "gt", "ge", "lt", "le", "in"]
358 }
359 }
360 .into_iter()
361 .map(String::from)
362 .collect();
363
364 _ = write!(description, "\n- {}: {}", name, ops.join("|"));
365 filter.allowed_fields.insert(name.clone(), ops);
366 }
367 self.spec.params.push(ParamSpec {
368 name: "$filter".to_owned(),
369 location: ParamLocation::Query,
370 required: false,
371 description: Some(description),
372 param_type: "string".to_owned(),
373 });
374 self.spec.vendor_extensions.x_odata_filter = Some(filter);
375 self
376 }
377
378 fn with_odata_select(mut self) -> Self {
379 self.spec.params.push(ParamSpec {
380 name: "$select".to_owned(),
381 location: ParamLocation::Query,
382 required: false,
383 description: Some("OData v4 select expression".to_owned()),
384 param_type: "string".to_owned(),
385 });
386 self
387 }
388
389 fn with_odata_orderby<T>(mut self) -> Self
390 where
391 T: toolkit_odata::filter::FilterField,
392 {
393 use std::fmt::Write as _;
394 let mut order_by = self
395 .spec
396 .vendor_extensions
397 .x_odata_orderby
398 .unwrap_or_default();
399 let mut description = "OData v4 orderby expression".to_owned();
400 for field in T::FIELDS {
401 let name = field.name().to_owned();
402
403 let asc = format!("{name} asc");
405 let desc = format!("{name} desc");
406
407 _ = write!(description, "\n- {asc}\n- {desc}");
408 if !order_by.allowed_fields.contains(&asc) {
409 order_by.allowed_fields.push(asc);
410 }
411 if !order_by.allowed_fields.contains(&desc) {
412 order_by.allowed_fields.push(desc);
413 }
414 }
415 self.spec.params.push(ParamSpec {
416 name: "$orderby".to_owned(),
417 location: ParamLocation::Query,
418 required: false,
419 description: Some(description),
420 param_type: "string".to_owned(),
421 });
422 self.spec.vendor_extensions.x_odata_orderby = Some(order_by);
423 self
424 }
425}
426
427pub use crate::api::openapi_registry::{OpenApiRegistry, ensure_schema};
429
430#[must_use]
439pub struct OperationBuilder<H = Missing, R = Missing, S = (), A = AuthNotSet, L = LicenseNotSet>
440where
441 H: HandlerSlot<S>,
442 A: AuthState,
443 L: LicenseState,
444{
445 spec: OperationSpec,
446 method_router: <H as HandlerSlot<S>>::Slot,
447 _has_handler: PhantomData<H>,
448 _has_response: PhantomData<R>,
449 #[allow(clippy::type_complexity)]
450 _state: PhantomData<fn() -> S>, _auth_state: PhantomData<A>,
452 _license_state: PhantomData<L>,
453}
454
455impl<S> OperationBuilder<Missing, Missing, S, AuthNotSet> {
459 pub fn new(method: Method, path: impl Into<String>) -> Self {
461 let path_str = path.into();
462 let handler_id = format!(
463 "{}:{}",
464 method.as_str().to_lowercase(),
465 path_str.replace(['/', '{', '}'], "_")
466 );
467
468 Self {
469 spec: OperationSpec {
470 method,
471 path: path_str,
472 operation_id: None,
473 summary: None,
474 description: None,
475 tags: Vec::new(),
476 params: Vec::new(),
477 request_body: None,
478 responses: Vec::new(),
479 handler_id,
480 authenticated: false,
481 is_public: false,
482 rate_limit: None,
483 allowed_request_content_types: None,
484 vendor_extensions: VendorExtensions::default(),
485 license_requirement: None,
486 },
487 method_router: (), _has_handler: PhantomData,
489 _has_response: PhantomData,
490 _state: PhantomData,
491 _auth_state: PhantomData,
492 _license_state: PhantomData,
493 }
494 }
495
496 pub fn get(path: impl Into<String>) -> Self {
498 let path_str = path.into();
499 Self::new(Method::GET, normalize_to_axum_path(&path_str))
500 }
501
502 pub fn post(path: impl Into<String>) -> Self {
504 let path_str = path.into();
505 Self::new(Method::POST, normalize_to_axum_path(&path_str))
506 }
507
508 pub fn put(path: impl Into<String>) -> Self {
510 let path_str = path.into();
511 Self::new(Method::PUT, normalize_to_axum_path(&path_str))
512 }
513
514 pub fn delete(path: impl Into<String>) -> Self {
516 let path_str = path.into();
517 Self::new(Method::DELETE, normalize_to_axum_path(&path_str))
518 }
519
520 pub fn patch(path: impl Into<String>) -> Self {
522 let path_str = path.into();
523 Self::new(Method::PATCH, normalize_to_axum_path(&path_str))
524 }
525}
526
527impl<H, R, S, A, L> OperationBuilder<H, R, S, A, L>
531where
532 H: HandlerSlot<S>,
533 A: AuthState,
534 L: LicenseState,
535{
536 pub fn spec(&self) -> &OperationSpec {
538 &self.spec
539 }
540
541 pub fn operation_id(mut self, id: impl Into<String>) -> Self {
543 self.spec.operation_id = Some(id.into());
544 self
545 }
546
547 pub fn require_rate_limit(&mut self, rps: u32, burst: u32, in_flight: u32) -> &mut Self {
550 self.spec.rate_limit = Some(RateLimitSpec {
551 rps,
552 burst,
553 in_flight,
554 });
555 self
556 }
557
558 pub fn summary(mut self, text: impl Into<String>) -> Self {
560 self.spec.summary = Some(text.into());
561 self
562 }
563
564 pub fn description(mut self, text: impl Into<String>) -> Self {
566 self.spec.description = Some(text.into());
567 self
568 }
569
570 pub fn tag(mut self, tag: impl Into<String>) -> Self {
572 self.spec.tags.push(tag.into());
573 self
574 }
575
576 pub fn param(mut self, param: ParamSpec) -> Self {
578 self.spec.params.push(param);
579 self
580 }
581
582 pub fn path_param(mut self, name: impl Into<String>, description: impl Into<String>) -> Self {
584 self.spec.params.push(ParamSpec {
585 name: name.into(),
586 location: ParamLocation::Path,
587 required: true,
588 description: Some(description.into()),
589 param_type: "string".to_owned(),
590 });
591 self
592 }
593
594 pub fn query_param(
596 mut self,
597 name: impl Into<String>,
598 required: bool,
599 description: impl Into<String>,
600 ) -> Self {
601 self.spec.params.push(ParamSpec {
602 name: name.into(),
603 location: ParamLocation::Query,
604 required,
605 description: Some(description.into()),
606 param_type: "string".to_owned(),
607 });
608 self
609 }
610
611 pub fn query_param_typed(
613 mut self,
614 name: impl Into<String>,
615 required: bool,
616 description: impl Into<String>,
617 param_type: impl Into<String>,
618 ) -> Self {
619 self.spec.params.push(ParamSpec {
620 name: name.into(),
621 location: ParamLocation::Query,
622 required,
623 description: Some(description.into()),
624 param_type: param_type.into(),
625 });
626 self
627 }
628
629 pub fn json_request_schema(
632 mut self,
633 schema_name: impl Into<String>,
634 desc: impl Into<String>,
635 ) -> Self {
636 self.spec.request_body = Some(RequestBodySpec {
637 content_type: "application/json",
638 description: Some(desc.into()),
639 schema: RequestBodySchema::Ref {
640 schema_name: schema_name.into(),
641 },
642 required: true,
643 });
644 self
645 }
646
647 pub fn json_request_schema_no_desc(mut self, schema_name: impl Into<String>) -> Self {
650 self.spec.request_body = Some(RequestBodySpec {
651 content_type: "application/json",
652 description: None,
653 schema: RequestBodySchema::Ref {
654 schema_name: schema_name.into(),
655 },
656 required: true,
657 });
658 self
659 }
660
661 pub fn json_request<T>(
664 mut self,
665 registry: &dyn OpenApiRegistry,
666 desc: impl Into<String>,
667 ) -> Self
668 where
669 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::RequestApiDto + 'static,
670 {
671 let name = ensure_schema::<T>(registry);
672 self.spec.request_body = Some(RequestBodySpec {
673 content_type: "application/json",
674 description: Some(desc.into()),
675 schema: RequestBodySchema::Ref { schema_name: name },
676 required: true,
677 });
678 self
679 }
680
681 pub fn json_request_no_desc<T>(mut self, registry: &dyn OpenApiRegistry) -> Self
684 where
685 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::RequestApiDto + 'static,
686 {
687 let name = ensure_schema::<T>(registry);
688 self.spec.request_body = Some(RequestBodySpec {
689 content_type: "application/json",
690 description: None,
691 schema: RequestBodySchema::Ref { schema_name: name },
692 required: true,
693 });
694 self
695 }
696
697 pub fn request_optional(mut self) -> Self {
699 if let Some(rb) = &mut self.spec.request_body {
700 rb.required = false;
701 }
702 self
703 }
704
705 pub fn multipart_file_request(mut self, field_name: &str, description: Option<&str>) -> Self {
743 self.spec.request_body = Some(RequestBodySpec {
745 content_type: "multipart/form-data",
746 description: description
747 .map(|s| format!("{s} (expects field '{field_name}' with file data)")),
748 schema: RequestBodySchema::MultipartFile {
749 field_name: field_name.to_owned(),
750 },
751 required: true,
752 });
753
754 self.spec.allowed_request_content_types = Some(vec!["multipart/form-data"]);
756
757 self
758 }
759
760 pub fn octet_stream_request(mut self, description: Option<&str>) -> Self {
804 self.spec.request_body = Some(RequestBodySpec {
805 content_type: "application/octet-stream",
806 description: description.map(str::to_owned),
807 schema: RequestBodySchema::Binary,
808 required: true,
809 });
810
811 self.spec.allowed_request_content_types = Some(vec!["application/octet-stream"]);
813
814 self
815 }
816
817 pub fn allow_content_types(mut self, types: &[&'static str]) -> Self {
847 self.spec.allowed_request_content_types = Some(types.to_vec());
848 self
849 }
850}
851
852impl<H, R, S> OperationBuilder<H, R, S, AuthSet, LicenseNotSet>
854where
855 H: HandlerSlot<S>,
856{
857 pub fn require_license_features<F>(
870 mut self,
871 licenses: impl IntoIterator<Item = F>,
872 ) -> OperationBuilder<H, R, S, AuthSet, LicenseSet>
873 where
874 F: LicenseFeature,
875 {
876 let license_names: Vec<String> = licenses
877 .into_iter()
878 .map(|l| l.as_ref().to_owned())
879 .collect();
880
881 self.spec.license_requirement =
882 (!license_names.is_empty()).then_some(LicenseReqSpec { license_names });
883
884 OperationBuilder {
885 spec: self.spec,
886 method_router: self.method_router,
887 _has_handler: self._has_handler,
888 _has_response: self._has_response,
889 _state: self._state,
890 _auth_state: self._auth_state,
891 _license_state: PhantomData,
892 }
893 }
894
895 pub fn no_license_required(self) -> OperationBuilder<H, R, S, AuthSet, LicenseSet> {
903 OperationBuilder {
904 spec: self.spec,
905 method_router: self.method_router,
906 _has_handler: self._has_handler,
907 _has_response: self._has_response,
908 _state: self._state,
909 _auth_state: self._auth_state,
910 _license_state: PhantomData,
911 }
912 }
913}
914
915impl<H, R, S, L> OperationBuilder<H, R, S, AuthNotSet, L>
919where
920 H: HandlerSlot<S>,
921 L: LicenseState,
922{
923 pub fn authenticated(mut self) -> OperationBuilder<H, R, S, AuthSet, L> {
973 self.spec.authenticated = true;
974 self.spec.is_public = false;
975 OperationBuilder {
976 spec: self.spec,
977 method_router: self.method_router,
978 _has_handler: self._has_handler,
979 _has_response: self._has_response,
980 _state: self._state,
981 _auth_state: PhantomData,
982 _license_state: self._license_state,
983 }
984 }
985
986 pub fn public(mut self) -> OperationBuilder<H, R, S, AuthSet, LicenseSet> {
1010 self.spec.is_public = true;
1011 self.spec.authenticated = false;
1012 OperationBuilder {
1013 spec: self.spec,
1014 method_router: self.method_router,
1015 _has_handler: self._has_handler,
1016 _has_response: self._has_response,
1017 _state: self._state,
1018 _auth_state: PhantomData,
1019 _license_state: PhantomData,
1020 }
1021 }
1022}
1023
1024impl<R, S, A, L> OperationBuilder<Missing, R, S, A, L>
1028where
1029 S: Clone + Send + Sync + 'static,
1030 A: AuthState,
1031 L: LicenseState,
1032{
1033 pub fn handler<F, T>(self, h: F) -> OperationBuilder<Present, R, S, A, L>
1037 where
1038 F: Handler<T, S> + Clone + Send + 'static,
1039 T: 'static,
1040 {
1041 let method_router = match self.spec.method {
1042 Method::GET => axum::routing::get(h),
1043 Method::POST => axum::routing::post(h),
1044 Method::PUT => axum::routing::put(h),
1045 Method::DELETE => axum::routing::delete(h),
1046 Method::PATCH => axum::routing::patch(h),
1047 _ => axum::routing::any(|| async { axum::http::StatusCode::METHOD_NOT_ALLOWED }),
1048 };
1049
1050 OperationBuilder {
1051 spec: self.spec,
1052 method_router, _has_handler: PhantomData::<Present>,
1054 _has_response: self._has_response,
1055 _state: self._state,
1056 _auth_state: self._auth_state,
1057 _license_state: self._license_state,
1058 }
1059 }
1060
1061 pub fn method_router(self, mr: MethodRouter<S>) -> OperationBuilder<Present, R, S, A, L> {
1064 OperationBuilder {
1065 spec: self.spec,
1066 method_router: mr, _has_handler: PhantomData::<Present>,
1068 _has_response: self._has_response,
1069 _state: self._state,
1070 _auth_state: self._auth_state,
1071 _license_state: self._license_state,
1072 }
1073 }
1074}
1075
1076impl<H, S, A, L> OperationBuilder<H, Missing, S, A, L>
1080where
1081 H: HandlerSlot<S>,
1082 A: AuthState,
1083 L: LicenseState,
1084{
1085 pub fn response(mut self, resp: ResponseSpec) -> OperationBuilder<H, Present, S, A, L> {
1087 self.spec.responses.push(resp);
1088 OperationBuilder {
1089 spec: self.spec,
1090 method_router: self.method_router,
1091 _has_handler: self._has_handler,
1092 _has_response: PhantomData::<Present>,
1093 _state: self._state,
1094 _auth_state: self._auth_state,
1095 _license_state: self._license_state,
1096 }
1097 }
1098
1099 pub fn json_response(
1101 mut self,
1102 status: http::StatusCode,
1103 description: impl Into<String>,
1104 ) -> OperationBuilder<H, Present, S, A, L> {
1105 self.spec.responses.push(ResponseSpec {
1106 status: status.as_u16(),
1107 content_type: "application/json",
1108 description: description.into(),
1109 schema: None,
1110 });
1111 OperationBuilder {
1112 spec: self.spec,
1113 method_router: self.method_router,
1114 _has_handler: self._has_handler,
1115 _has_response: PhantomData::<Present>,
1116 _state: self._state,
1117 _auth_state: self._auth_state,
1118 _license_state: self._license_state,
1119 }
1120 }
1121
1122 pub fn no_content_response(
1130 mut self,
1131 status: http::StatusCode,
1132 description: impl Into<String>,
1133 ) -> OperationBuilder<H, Present, S, A, L> {
1134 self.spec.responses.push(ResponseSpec {
1135 status: status.as_u16(),
1136 content_type: "",
1137 description: description.into(),
1138 schema: None,
1139 });
1140 OperationBuilder {
1141 spec: self.spec,
1142 method_router: self.method_router,
1143 _has_handler: self._has_handler,
1144 _has_response: PhantomData::<Present>,
1145 _state: self._state,
1146 _auth_state: self._auth_state,
1147 _license_state: self._license_state,
1148 }
1149 }
1150
1151 pub fn json_response_with_schema<T>(
1153 mut self,
1154 registry: &dyn OpenApiRegistry,
1155 status: http::StatusCode,
1156 description: impl Into<String>,
1157 ) -> OperationBuilder<H, Present, S, A, L>
1158 where
1159 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
1160 {
1161 let name = ensure_schema::<T>(registry);
1162 self.spec.responses.push(ResponseSpec {
1163 status: status.as_u16(),
1164 content_type: "application/json",
1165 description: description.into(),
1166 schema: Some(ResponseSchema::Ref { schema_name: name }),
1167 });
1168 OperationBuilder {
1169 spec: self.spec,
1170 method_router: self.method_router,
1171 _has_handler: self._has_handler,
1172 _has_response: PhantomData::<Present>,
1173 _state: self._state,
1174 _auth_state: self._auth_state,
1175 _license_state: self._license_state,
1176 }
1177 }
1178
1179 pub fn json_array_response_with_schema<T>(
1191 mut self,
1192 registry: &dyn OpenApiRegistry,
1193 status: http::StatusCode,
1194 description: impl Into<String>,
1195 ) -> OperationBuilder<H, Present, S, A, L>
1196 where
1197 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
1198 {
1199 let items_schema_name = ensure_schema::<T>(registry);
1200 self.spec.responses.push(ResponseSpec {
1201 status: status.as_u16(),
1202 content_type: "application/json",
1203 description: description.into(),
1204 schema: Some(ResponseSchema::Array { items_schema_name }),
1205 });
1206 OperationBuilder {
1207 spec: self.spec,
1208 method_router: self.method_router,
1209 _has_handler: self._has_handler,
1210 _has_response: PhantomData::<Present>,
1211 _state: self._state,
1212 _auth_state: self._auth_state,
1213 _license_state: self._license_state,
1214 }
1215 }
1216
1217 pub fn text_response(
1230 mut self,
1231 status: http::StatusCode,
1232 description: impl Into<String>,
1233 content_type: &'static str,
1234 ) -> OperationBuilder<H, Present, S, A, L> {
1235 self.spec.responses.push(ResponseSpec {
1236 status: status.as_u16(),
1237 content_type,
1238 description: description.into(),
1239 schema: None,
1240 });
1241 OperationBuilder {
1242 spec: self.spec,
1243 method_router: self.method_router,
1244 _has_handler: self._has_handler,
1245 _has_response: PhantomData::<Present>,
1246 _state: self._state,
1247 _auth_state: self._auth_state,
1248 _license_state: self._license_state,
1249 }
1250 }
1251
1252 pub fn html_response(
1254 mut self,
1255 status: http::StatusCode,
1256 description: impl Into<String>,
1257 ) -> OperationBuilder<H, Present, S, A, L> {
1258 self.spec.responses.push(ResponseSpec {
1259 status: status.as_u16(),
1260 content_type: "text/html",
1261 description: description.into(),
1262 schema: None,
1263 });
1264 OperationBuilder {
1265 spec: self.spec,
1266 method_router: self.method_router,
1267 _has_handler: self._has_handler,
1268 _has_response: PhantomData::<Present>,
1269 _state: self._state,
1270 _auth_state: self._auth_state,
1271 _license_state: self._license_state,
1272 }
1273 }
1274
1275 pub fn problem_response(
1277 mut self,
1278 registry: &dyn OpenApiRegistry,
1279 status: http::StatusCode,
1280 description: impl Into<String>,
1281 ) -> OperationBuilder<H, Present, S, A, L> {
1282 let problem_name = ensure_schema::<toolkit_canonical_errors::Problem>(registry);
1284 self.spec.responses.push(ResponseSpec {
1285 status: status.as_u16(),
1286 content_type: problem::APPLICATION_PROBLEM_JSON,
1287 description: description.into(),
1288 schema: Some(ResponseSchema::Ref {
1289 schema_name: problem_name,
1290 }),
1291 });
1292 OperationBuilder {
1293 spec: self.spec,
1294 method_router: self.method_router,
1295 _has_handler: self._has_handler,
1296 _has_response: PhantomData::<Present>,
1297 _state: self._state,
1298 _auth_state: self._auth_state,
1299 _license_state: self._license_state,
1300 }
1301 }
1302
1303 pub fn sse_json<T>(
1305 mut self,
1306 openapi: &dyn OpenApiRegistry,
1307 description: impl Into<String>,
1308 ) -> OperationBuilder<H, Present, S, A, L>
1309 where
1310 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
1311 {
1312 let name = ensure_schema::<T>(openapi);
1313 self.spec.responses.push(ResponseSpec {
1314 status: http::StatusCode::OK.as_u16(),
1315 content_type: "text/event-stream",
1316 description: description.into(),
1317 schema: Some(ResponseSchema::Ref { schema_name: name }),
1318 });
1319 OperationBuilder {
1320 spec: self.spec,
1321 method_router: self.method_router,
1322 _has_handler: self._has_handler,
1323 _has_response: PhantomData::<Present>,
1324 _state: self._state,
1325 _auth_state: self._auth_state,
1326 _license_state: self._license_state,
1327 }
1328 }
1329}
1330
1331impl<H, S, A, L> OperationBuilder<H, Present, S, A, L>
1335where
1336 H: HandlerSlot<S>,
1337 A: AuthState,
1338 L: LicenseState,
1339{
1340 pub fn json_response(
1342 mut self,
1343 status: http::StatusCode,
1344 description: impl Into<String>,
1345 ) -> Self {
1346 self.spec.responses.push(ResponseSpec {
1347 status: status.as_u16(),
1348 content_type: "application/json",
1349 description: description.into(),
1350 schema: None,
1351 });
1352 self
1353 }
1354
1355 pub fn no_content_response(
1357 mut self,
1358 status: http::StatusCode,
1359 description: impl Into<String>,
1360 ) -> Self {
1361 self.spec.responses.push(ResponseSpec {
1362 status: status.as_u16(),
1363 content_type: "",
1364 description: description.into(),
1365 schema: None,
1366 });
1367 self
1368 }
1369
1370 pub fn json_response_with_schema<T>(
1372 mut self,
1373 registry: &dyn OpenApiRegistry,
1374 status: http::StatusCode,
1375 description: impl Into<String>,
1376 ) -> Self
1377 where
1378 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
1379 {
1380 let name = ensure_schema::<T>(registry);
1381 self.spec.responses.push(ResponseSpec {
1382 status: status.as_u16(),
1383 content_type: "application/json",
1384 description: description.into(),
1385 schema: Some(ResponseSchema::Ref { schema_name: name }),
1386 });
1387 self
1388 }
1389
1390 pub fn json_array_response_with_schema<T>(
1396 mut self,
1397 registry: &dyn OpenApiRegistry,
1398 status: http::StatusCode,
1399 description: impl Into<String>,
1400 ) -> Self
1401 where
1402 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
1403 {
1404 let items_schema_name = ensure_schema::<T>(registry);
1405 self.spec.responses.push(ResponseSpec {
1406 status: status.as_u16(),
1407 content_type: "application/json",
1408 description: description.into(),
1409 schema: Some(ResponseSchema::Array { items_schema_name }),
1410 });
1411 self
1412 }
1413
1414 pub fn text_response(
1427 mut self,
1428 status: http::StatusCode,
1429 description: impl Into<String>,
1430 content_type: &'static str,
1431 ) -> Self {
1432 self.spec.responses.push(ResponseSpec {
1433 status: status.as_u16(),
1434 content_type,
1435 description: description.into(),
1436 schema: None,
1437 });
1438 self
1439 }
1440
1441 pub fn html_response(
1443 mut self,
1444 status: http::StatusCode,
1445 description: impl Into<String>,
1446 ) -> Self {
1447 self.spec.responses.push(ResponseSpec {
1448 status: status.as_u16(),
1449 content_type: "text/html",
1450 description: description.into(),
1451 schema: None,
1452 });
1453 self
1454 }
1455
1456 pub fn problem_response(
1458 mut self,
1459 registry: &dyn OpenApiRegistry,
1460 status: http::StatusCode,
1461 description: impl Into<String>,
1462 ) -> Self {
1463 let problem_name = ensure_schema::<toolkit_canonical_errors::Problem>(registry);
1465 self.spec.responses.push(ResponseSpec {
1466 status: status.as_u16(),
1467 content_type: problem::APPLICATION_PROBLEM_JSON,
1468 description: description.into(),
1469 schema: Some(ResponseSchema::Ref {
1470 schema_name: problem_name,
1471 }),
1472 });
1473 self
1474 }
1475
1476 pub fn sse_json<T>(
1478 mut self,
1479 openapi: &dyn OpenApiRegistry,
1480 description: impl Into<String>,
1481 ) -> Self
1482 where
1483 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
1484 {
1485 let name = ensure_schema::<T>(openapi);
1486 self.spec.responses.push(ResponseSpec {
1487 status: http::StatusCode::OK.as_u16(),
1488 content_type: "text/event-stream",
1489 description: description.into(),
1490 schema: Some(ResponseSchema::Ref { schema_name: name }),
1491 });
1492 self
1493 }
1494
1495 pub fn standard_errors(mut self, registry: &dyn OpenApiRegistry) -> Self {
1537 use http::StatusCode;
1538 let problem_name = ensure_schema::<toolkit_canonical_errors::Problem>(registry);
1540
1541 let standard_errors = [
1542 (StatusCode::BAD_REQUEST, "Bad Request"),
1543 (StatusCode::UNAUTHORIZED, "Unauthorized"),
1544 (StatusCode::FORBIDDEN, "Forbidden"),
1545 (StatusCode::NOT_FOUND, "Not Found"),
1546 (StatusCode::CONFLICT, "Conflict"),
1547 (StatusCode::TOO_MANY_REQUESTS, "Too Many Requests"),
1548 (StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error"),
1549 ];
1550
1551 for (status, description) in standard_errors {
1552 self.spec.responses.push(ResponseSpec {
1553 status: status.as_u16(),
1554 content_type: problem::APPLICATION_PROBLEM_JSON,
1555 description: description.to_owned(),
1556 schema: Some(ResponseSchema::Ref {
1557 schema_name: problem_name.clone(),
1558 }),
1559 });
1560 }
1561
1562 self
1563 }
1564
1565 pub fn with_400_validation_error(mut self, registry: &dyn OpenApiRegistry) -> Self {
1602 let problem_name = ensure_schema::<toolkit_canonical_errors::Problem>(registry);
1603
1604 self.spec.responses.push(ResponseSpec {
1605 status: http::StatusCode::BAD_REQUEST.as_u16(),
1606 content_type: problem::APPLICATION_PROBLEM_JSON,
1607 description: "Validation Error".to_owned(),
1608 schema: Some(ResponseSchema::Ref {
1609 schema_name: problem_name,
1610 }),
1611 });
1612
1613 self
1614 }
1615
1616 pub fn error_400(self, registry: &dyn OpenApiRegistry) -> Self {
1620 self.problem_response(registry, http::StatusCode::BAD_REQUEST, "Bad Request")
1621 }
1622
1623 pub fn error_401(self, registry: &dyn OpenApiRegistry) -> Self {
1627 self.problem_response(registry, http::StatusCode::UNAUTHORIZED, "Unauthorized")
1628 }
1629
1630 pub fn error_403(self, registry: &dyn OpenApiRegistry) -> Self {
1634 self.problem_response(registry, http::StatusCode::FORBIDDEN, "Forbidden")
1635 }
1636
1637 pub fn error_404(self, registry: &dyn OpenApiRegistry) -> Self {
1641 self.problem_response(registry, http::StatusCode::NOT_FOUND, "Not Found")
1642 }
1643
1644 pub fn error_409(self, registry: &dyn OpenApiRegistry) -> Self {
1648 self.problem_response(registry, http::StatusCode::CONFLICT, "Conflict")
1649 }
1650
1651 pub fn error_415(self, registry: &dyn OpenApiRegistry) -> Self {
1655 self.problem_response(
1656 registry,
1657 http::StatusCode::UNSUPPORTED_MEDIA_TYPE,
1658 "Unsupported Media Type",
1659 )
1660 }
1661
1662 pub fn error_422(self, registry: &dyn OpenApiRegistry) -> Self {
1666 self.problem_response(
1667 registry,
1668 http::StatusCode::UNPROCESSABLE_ENTITY,
1669 "Unprocessable Entity",
1670 )
1671 }
1672
1673 pub fn error_429(self, registry: &dyn OpenApiRegistry) -> Self {
1677 self.problem_response(
1678 registry,
1679 http::StatusCode::TOO_MANY_REQUESTS,
1680 "Too Many Requests",
1681 )
1682 }
1683
1684 pub fn error_500(self, registry: &dyn OpenApiRegistry) -> Self {
1688 self.problem_response(
1689 registry,
1690 http::StatusCode::INTERNAL_SERVER_ERROR,
1691 "Internal Server Error",
1692 )
1693 }
1694
1695 pub fn error_502(self, registry: &dyn OpenApiRegistry) -> Self {
1699 self.problem_response(registry, http::StatusCode::BAD_GATEWAY, "Bad Gateway")
1700 }
1701
1702 pub fn error_503(self, registry: &dyn OpenApiRegistry) -> Self {
1706 self.problem_response(
1707 registry,
1708 http::StatusCode::SERVICE_UNAVAILABLE,
1709 "Service Unavailable",
1710 )
1711 }
1712
1713 pub fn error_504(self, registry: &dyn OpenApiRegistry) -> Self {
1717 self.problem_response(
1718 registry,
1719 http::StatusCode::GATEWAY_TIMEOUT,
1720 "Gateway Timeout",
1721 )
1722 }
1723}
1724
1725impl<S> OperationBuilder<Present, Present, S, AuthSet, LicenseSet>
1729where
1730 S: Clone + Send + Sync + 'static,
1731{
1732 pub fn register(self, router: Router<S>, openapi: &dyn OpenApiRegistry) -> Router<S> {
1741 openapi.register_operation(&self.spec);
1744
1745 router.route(&self.spec.path, self.method_router)
1747 }
1748}
1749
1750#[cfg(test)]
1754#[cfg_attr(coverage_nightly, coverage(off))]
1755mod tests {
1756 use super::*;
1757 use axum::Json;
1758
1759 struct MockRegistry {
1761 operations: std::sync::Mutex<Vec<OperationSpec>>,
1762 schemas: std::sync::Mutex<Vec<String>>,
1763 }
1764
1765 impl MockRegistry {
1766 fn new() -> Self {
1767 Self {
1768 operations: std::sync::Mutex::new(Vec::new()),
1769 schemas: std::sync::Mutex::new(Vec::new()),
1770 }
1771 }
1772 }
1773
1774 enum TestLicenseFeatures {
1775 FeatureA,
1776 FeatureB,
1777 }
1778 impl AsRef<str> for TestLicenseFeatures {
1779 fn as_ref(&self) -> &str {
1780 match self {
1781 TestLicenseFeatures::FeatureA => "feature_a",
1782 TestLicenseFeatures::FeatureB => "feature_b",
1783 }
1784 }
1785 }
1786 impl LicenseFeature for TestLicenseFeatures {}
1787
1788 impl OpenApiRegistry for MockRegistry {
1789 fn register_operation(&self, spec: &OperationSpec) {
1790 if let Ok(mut ops) = self.operations.lock() {
1791 ops.push(spec.clone());
1792 }
1793 }
1794
1795 fn ensure_schema_raw(
1796 &self,
1797 name: &str,
1798 _schemas: Vec<(
1799 String,
1800 utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>,
1801 )>,
1802 ) -> String {
1803 let name = name.to_owned();
1804 if let Ok(mut s) = self.schemas.lock() {
1805 s.push(name.clone());
1806 }
1807 name
1808 }
1809
1810 fn as_any(&self) -> &dyn std::any::Any {
1811 self
1812 }
1813 }
1814
1815 async fn test_handler() -> Json<serde_json::Value> {
1816 Json(serde_json::json!({"status": "ok"}))
1817 }
1818
1819 #[toolkit_macros::api_dto(request)]
1820 struct SampleDtoRequest;
1821
1822 #[toolkit_macros::api_dto(response)]
1823 struct SampleDtoResponse;
1824
1825 #[test]
1826 fn builder_descriptive_methods() {
1827 let builder = OperationBuilder::<Missing, Missing, (), AuthNotSet>::get("/tests/v1/test")
1828 .operation_id("test.get")
1829 .summary("Test endpoint")
1830 .description("A test endpoint for validation")
1831 .tag("test")
1832 .path_param("id", "Test ID");
1833
1834 assert_eq!(builder.spec.method, Method::GET);
1835 assert_eq!(builder.spec.path, "/tests/v1/test");
1836 assert_eq!(builder.spec.operation_id, Some("test.get".to_owned()));
1837 assert_eq!(builder.spec.summary, Some("Test endpoint".to_owned()));
1838 assert_eq!(
1839 builder.spec.description,
1840 Some("A test endpoint for validation".to_owned())
1841 );
1842 assert_eq!(builder.spec.tags, vec!["test"]);
1843 assert_eq!(builder.spec.params.len(), 1);
1844 }
1845
1846 #[tokio::test]
1847 async fn builder_with_request_response_and_handler() {
1848 let registry = MockRegistry::new();
1849 let router = Router::new();
1850
1851 let _router = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
1852 .summary("Test endpoint")
1853 .json_request::<SampleDtoRequest>(®istry, "optional body") .public()
1855 .handler(test_handler)
1856 .json_response_with_schema::<SampleDtoResponse>(
1857 ®istry,
1858 http::StatusCode::OK,
1859 "Success response",
1860 ) .register(router, ®istry);
1862
1863 let ops = registry.operations.lock().unwrap();
1865 assert_eq!(ops.len(), 1);
1866 let op = &ops[0];
1867 assert_eq!(op.method, Method::POST);
1868 assert_eq!(op.path, "/tests/v1/test");
1869 assert!(op.request_body.is_some());
1870 assert!(op.request_body.as_ref().unwrap().required);
1871 assert_eq!(op.responses.len(), 1);
1872 assert_eq!(op.responses[0].status, 200);
1873
1874 let schemas = registry.schemas.lock().unwrap();
1876 assert!(!schemas.is_empty());
1877 }
1878
1879 #[test]
1880 fn convenience_constructors() {
1881 let get_builder =
1882 OperationBuilder::<Missing, Missing, (), AuthNotSet>::get("/tests/v1/get");
1883 assert_eq!(get_builder.spec.method, Method::GET);
1884 assert_eq!(get_builder.spec.path, "/tests/v1/get");
1885
1886 let post_builder =
1887 OperationBuilder::<Missing, Missing, (), AuthNotSet>::post("/tests/v1/post");
1888 assert_eq!(post_builder.spec.method, Method::POST);
1889 assert_eq!(post_builder.spec.path, "/tests/v1/post");
1890
1891 let put_builder =
1892 OperationBuilder::<Missing, Missing, (), AuthNotSet>::put("/tests/v1/put");
1893 assert_eq!(put_builder.spec.method, Method::PUT);
1894 assert_eq!(put_builder.spec.path, "/tests/v1/put");
1895
1896 let delete_builder =
1897 OperationBuilder::<Missing, Missing, (), AuthNotSet>::delete("/tests/v1/delete");
1898 assert_eq!(delete_builder.spec.method, Method::DELETE);
1899 assert_eq!(delete_builder.spec.path, "/tests/v1/delete");
1900
1901 let patch_builder =
1902 OperationBuilder::<Missing, Missing, (), AuthNotSet>::patch("/tests/v1/patch");
1903 assert_eq!(patch_builder.spec.method, Method::PATCH);
1904 assert_eq!(patch_builder.spec.path, "/tests/v1/patch");
1905 }
1906
1907 #[test]
1908 fn normalize_to_axum_path_should_normalize() {
1909 assert_eq!(
1911 normalize_to_axum_path("/tests/v1/users/{id}"),
1912 "/tests/v1/users/{id}"
1913 );
1914 assert_eq!(
1915 normalize_to_axum_path("/tests/v1/projects/{project_id}/items/{item_id}"),
1916 "/tests/v1/projects/{project_id}/items/{item_id}"
1917 );
1918 assert_eq!(
1919 normalize_to_axum_path("/tests/v1/simple"),
1920 "/tests/v1/simple"
1921 );
1922 assert_eq!(
1923 normalize_to_axum_path("/tests/v1/users/{id}/edit"),
1924 "/tests/v1/users/{id}/edit"
1925 );
1926 }
1927
1928 #[test]
1929 fn axum_to_openapi_path_should_convert() {
1930 assert_eq!(
1932 axum_to_openapi_path("/tests/v1/users/{id}"),
1933 "/tests/v1/users/{id}"
1934 );
1935 assert_eq!(
1936 axum_to_openapi_path("/tests/v1/projects/{project_id}/items/{item_id}"),
1937 "/tests/v1/projects/{project_id}/items/{item_id}"
1938 );
1939 assert_eq!(axum_to_openapi_path("/tests/v1/simple"), "/tests/v1/simple");
1940 assert_eq!(
1942 axum_to_openapi_path("/tests/v1/static/{*path}"),
1943 "/tests/v1/static/{path}"
1944 );
1945 assert_eq!(
1946 axum_to_openapi_path("/tests/v1/files/{*filepath}"),
1947 "/tests/v1/files/{filepath}"
1948 );
1949 }
1950
1951 #[test]
1952 fn path_normalization_in_constructors() {
1953 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/users/{id}");
1955 assert_eq!(builder.spec.path, "/tests/v1/users/{id}");
1956
1957 let builder = OperationBuilder::<Missing, Missing, ()>::post(
1958 "/tests/v1/projects/{project_id}/items/{item_id}",
1959 );
1960 assert_eq!(
1961 builder.spec.path,
1962 "/tests/v1/projects/{project_id}/items/{item_id}"
1963 );
1964
1965 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/simple");
1967 assert_eq!(builder.spec.path, "/tests/v1/simple");
1968 }
1969
1970 #[test]
1971 fn standard_errors() {
1972 let registry = MockRegistry::new();
1973 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1974 .public()
1975 .handler(test_handler)
1976 .json_response(http::StatusCode::OK, "Success")
1977 .standard_errors(®istry);
1978
1979 assert_eq!(builder.spec.responses.len(), 8);
1982
1983 let statuses: Vec<u16> = builder.spec.responses.iter().map(|r| r.status).collect();
1985 assert!(statuses.contains(&200)); assert!(statuses.contains(&400));
1987 assert!(statuses.contains(&401));
1988 assert!(statuses.contains(&403));
1989 assert!(statuses.contains(&404));
1990 assert!(statuses.contains(&409));
1991 assert!(!statuses.contains(&422));
1992 assert!(statuses.contains(&429));
1993 assert!(statuses.contains(&500));
1994
1995 let error_responses: Vec<_> = builder
1997 .spec
1998 .responses
1999 .iter()
2000 .filter(|r| r.status >= 400)
2001 .collect();
2002
2003 for resp in error_responses {
2004 assert_eq!(
2005 resp.content_type,
2006 toolkit_canonical_errors::problem::APPLICATION_PROBLEM_JSON
2007 );
2008 assert!(resp.schema_name().is_some());
2009 }
2010 }
2011
2012 #[test]
2013 fn authenticated() {
2014 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
2015 .authenticated()
2016 .handler(test_handler)
2017 .json_response(http::StatusCode::OK, "Success");
2018
2019 assert!(builder.spec.authenticated);
2020 assert!(!builder.spec.is_public);
2021 }
2022
2023 #[test]
2024 fn require_license_features_none() {
2025 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
2026 .authenticated()
2027 .require_license_features::<TestLicenseFeatures>([])
2028 .handler(|| async {})
2029 .json_response(http::StatusCode::OK, "OK");
2030
2031 assert!(builder.spec.license_requirement.is_none());
2032 }
2033
2034 #[test]
2035 fn no_license_required_transitions_and_allows_register() {
2036 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
2037 .authenticated()
2038 .no_license_required()
2039 .handler(|| async {})
2040 .json_response(http::StatusCode::OK, "OK");
2041
2042 assert!(builder.spec.license_requirement.is_none());
2043 assert!(!builder.spec.is_public);
2044 }
2045
2046 #[test]
2047 fn require_license_features_one() {
2048 let feature = TestLicenseFeatures::FeatureA;
2049
2050 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
2051 .authenticated()
2052 .require_license_features([&feature])
2053 .handler(|| async {})
2054 .json_response(http::StatusCode::OK, "OK");
2055
2056 let license_req = builder
2057 .spec
2058 .license_requirement
2059 .as_ref()
2060 .expect("Should have license requirement");
2061 assert_eq!(license_req.license_names, vec!["feature_a".to_owned()]);
2062 }
2063
2064 #[test]
2065 fn require_license_features_many() {
2066 let feature_a = TestLicenseFeatures::FeatureA;
2067 let feature_b = TestLicenseFeatures::FeatureB;
2068
2069 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
2070 .authenticated()
2071 .require_license_features([&feature_a, &feature_b])
2072 .handler(|| async {})
2073 .json_response(http::StatusCode::OK, "OK");
2074
2075 let license_req = builder
2076 .spec
2077 .license_requirement
2078 .as_ref()
2079 .expect("Should have license requirement");
2080 assert_eq!(
2081 license_req.license_names,
2082 vec!["feature_a".to_owned(), "feature_b".to_owned()]
2083 );
2084 }
2085
2086 #[tokio::test]
2087 async fn public_does_not_require_license_features_and_can_register() {
2088 let registry = MockRegistry::new();
2089 let router = Router::new();
2090
2091 let _router = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
2092 .public()
2093 .handler(test_handler)
2094 .json_response(http::StatusCode::OK, "Success")
2095 .register(router, ®istry);
2096
2097 let ops = registry.operations.lock().unwrap();
2098 assert_eq!(ops.len(), 1);
2099 assert!(ops[0].license_requirement.is_none());
2100 }
2101
2102 #[test]
2103 fn with_400_validation_error() {
2104 let registry = MockRegistry::new();
2105 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
2106 .public()
2107 .handler(test_handler)
2108 .json_response(http::StatusCode::CREATED, "Created")
2109 .with_400_validation_error(®istry);
2110
2111 assert_eq!(builder.spec.responses.len(), 2);
2113
2114 let validation_response = builder
2115 .spec
2116 .responses
2117 .iter()
2118 .find(|r| r.status == 400)
2119 .expect("Should have 400 response");
2120
2121 assert_eq!(validation_response.description, "Validation Error");
2122 assert_eq!(
2123 validation_response.content_type,
2124 toolkit_canonical_errors::problem::APPLICATION_PROBLEM_JSON
2125 );
2126 assert!(validation_response.schema_name().is_some());
2127 }
2128
2129 #[test]
2130 fn allow_content_types_with_existing_request_body() {
2131 let registry = MockRegistry::new();
2132 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
2133 .json_request::<SampleDtoRequest>(®istry, "Test request")
2134 .allow_content_types(&["application/json", "application/xml"])
2135 .public()
2136 .handler(test_handler)
2137 .json_response(http::StatusCode::OK, "Success");
2138
2139 assert!(builder.spec.request_body.is_some());
2141 assert!(builder.spec.allowed_request_content_types.is_some());
2142 let allowed = builder.spec.allowed_request_content_types.as_ref().unwrap();
2143 assert_eq!(allowed.len(), 2);
2144 assert!(allowed.contains(&"application/json"));
2145 assert!(allowed.contains(&"application/xml"));
2146 }
2147
2148 #[test]
2149 fn allow_content_types_without_existing_request_body() {
2150 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
2151 .allow_content_types(&["multipart/form-data"])
2152 .public()
2153 .handler(test_handler)
2154 .json_response(http::StatusCode::OK, "Success");
2155
2156 assert!(builder.spec.request_body.is_none());
2158 assert!(builder.spec.allowed_request_content_types.is_some());
2159 let allowed = builder.spec.allowed_request_content_types.as_ref().unwrap();
2160 assert_eq!(allowed.len(), 1);
2161 assert!(allowed.contains(&"multipart/form-data"));
2162 }
2163
2164 #[test]
2165 fn allow_content_types_can_be_chained() {
2166 let registry = MockRegistry::new();
2167 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
2168 .operation_id("test.post")
2169 .summary("Test endpoint")
2170 .json_request::<SampleDtoRequest>(®istry, "Test request")
2171 .allow_content_types(&["application/json"])
2172 .public()
2173 .handler(test_handler)
2174 .json_response(http::StatusCode::OK, "Success")
2175 .problem_response(
2176 ®istry,
2177 http::StatusCode::UNSUPPORTED_MEDIA_TYPE,
2178 "Unsupported Media Type",
2179 );
2180
2181 assert_eq!(builder.spec.operation_id, Some("test.post".to_owned()));
2182 assert!(builder.spec.request_body.is_some());
2183 assert!(builder.spec.allowed_request_content_types.is_some());
2184 assert_eq!(builder.spec.responses.len(), 2);
2185 }
2186
2187 #[test]
2188 fn multipart_file_request() {
2189 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/upload")
2190 .operation_id("test.upload")
2191 .summary("Upload file")
2192 .multipart_file_request("file", Some("Upload a file"))
2193 .public()
2194 .handler(test_handler)
2195 .json_response(http::StatusCode::OK, "Success");
2196
2197 assert!(builder.spec.request_body.is_some());
2199 let rb = builder.spec.request_body.as_ref().unwrap();
2200 assert_eq!(rb.content_type, "multipart/form-data");
2201 assert!(rb.description.is_some());
2202 assert!(rb.description.as_ref().unwrap().contains("file"));
2203 assert!(rb.required);
2204
2205 assert_eq!(
2207 rb.schema,
2208 RequestBodySchema::MultipartFile {
2209 field_name: "file".to_owned()
2210 }
2211 );
2212
2213 assert!(builder.spec.allowed_request_content_types.is_some());
2215 let allowed = builder.spec.allowed_request_content_types.as_ref().unwrap();
2216 assert_eq!(allowed.len(), 1);
2217 assert!(allowed.contains(&"multipart/form-data"));
2218 }
2219
2220 #[test]
2221 fn multipart_file_request_without_description() {
2222 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/upload")
2223 .multipart_file_request("file", None)
2224 .public()
2225 .handler(test_handler)
2226 .json_response(http::StatusCode::OK, "Success");
2227
2228 assert!(builder.spec.request_body.is_some());
2229 let rb = builder.spec.request_body.as_ref().unwrap();
2230 assert_eq!(rb.content_type, "multipart/form-data");
2231 assert!(rb.description.is_none());
2232 assert_eq!(
2233 rb.schema,
2234 RequestBodySchema::MultipartFile {
2235 field_name: "file".to_owned()
2236 }
2237 );
2238 }
2239
2240 #[test]
2241 fn octet_stream_request() {
2242 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/upload")
2243 .operation_id("test.upload")
2244 .summary("Upload raw file")
2245 .octet_stream_request(Some("Raw file bytes"))
2246 .public()
2247 .handler(test_handler)
2248 .json_response(http::StatusCode::OK, "Success");
2249
2250 assert!(builder.spec.request_body.is_some());
2252 let rb = builder.spec.request_body.as_ref().unwrap();
2253 assert_eq!(rb.content_type, "application/octet-stream");
2254 assert_eq!(rb.description, Some("Raw file bytes".to_owned()));
2255 assert!(rb.required);
2256
2257 assert_eq!(rb.schema, RequestBodySchema::Binary);
2259
2260 assert!(builder.spec.allowed_request_content_types.is_some());
2262 let allowed = builder.spec.allowed_request_content_types.as_ref().unwrap();
2263 assert_eq!(allowed.len(), 1);
2264 assert!(allowed.contains(&"application/octet-stream"));
2265 }
2266
2267 #[test]
2268 fn octet_stream_request_without_description() {
2269 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/upload")
2270 .octet_stream_request(None)
2271 .public()
2272 .handler(test_handler)
2273 .json_response(http::StatusCode::OK, "Success");
2274
2275 assert!(builder.spec.request_body.is_some());
2276 let rb = builder.spec.request_body.as_ref().unwrap();
2277 assert_eq!(rb.content_type, "application/octet-stream");
2278 assert!(rb.description.is_none());
2279 assert_eq!(rb.schema, RequestBodySchema::Binary);
2280 }
2281
2282 #[test]
2283 fn json_request_uses_ref_schema() {
2284 let registry = MockRegistry::new();
2285 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
2286 .json_request::<SampleDtoRequest>(®istry, "Test request body")
2287 .public()
2288 .handler(test_handler)
2289 .json_response(http::StatusCode::OK, "Success");
2290
2291 assert!(builder.spec.request_body.is_some());
2292 let rb = builder.spec.request_body.as_ref().unwrap();
2293 assert_eq!(rb.content_type, "application/json");
2294
2295 match &rb.schema {
2297 RequestBodySchema::Ref { schema_name } => {
2298 assert!(!schema_name.is_empty());
2299 }
2300 _ => panic!("Expected RequestBodySchema::Ref for JSON request"),
2301 }
2302 }
2303
2304 #[test]
2305 fn response_content_types_must_not_contain_parameters() {
2306 let registry = MockRegistry::new();
2309 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
2310 .operation_id("test.content_type_purity")
2311 .summary("Test response content types")
2312 .json_request::<SampleDtoRequest>(®istry, "Test")
2313 .public()
2314 .handler(test_handler)
2315 .text_response(http::StatusCode::OK, "Text", "text/plain")
2316 .text_response(http::StatusCode::OK, "Markdown", "text/markdown")
2317 .html_response(http::StatusCode::OK, "HTML")
2318 .json_response(http::StatusCode::OK, "JSON")
2319 .problem_response(®istry, http::StatusCode::BAD_REQUEST, "Error");
2320
2321 for response in &builder.spec.responses {
2323 assert!(
2324 !response.content_type.contains(';'),
2325 "Response content_type '{}' must not contain parameters. \
2326 Use pure media type without charset or other parameters. \
2327 OpenAPI media type keys cannot include parameters.",
2328 response.content_type
2329 );
2330 }
2331 }
2332}