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)]
187pub struct ResponseSpec {
188 pub status: u16,
189 pub content_type: &'static str,
190 pub description: String,
191 pub schema_name: Option<String>,
193}
194
195#[derive(Clone, Debug)]
197pub struct LicenseReqSpec {
198 pub license_names: Vec<String>,
199}
200
201#[derive(Clone, Debug)]
203pub struct OperationSpec {
204 pub method: Method,
205 pub path: String,
206 pub operation_id: Option<String>,
207 pub summary: Option<String>,
208 pub description: Option<String>,
209 pub tags: Vec<String>,
210 pub params: Vec<ParamSpec>,
211 pub request_body: Option<RequestBodySpec>,
212 pub responses: Vec<ResponseSpec>,
213 pub handler_id: String,
215 pub authenticated: bool,
218 pub is_public: bool,
220 pub rate_limit: Option<RateLimitSpec>,
222 pub allowed_request_content_types: Option<Vec<&'static str>>,
228 pub vendor_extensions: VendorExtensions,
230 pub license_requirement: Option<LicenseReqSpec>,
231}
232
233#[derive(Clone, Debug, Default, Deserialize, Serialize)]
234pub struct VendorExtensions {
235 #[serde(rename = "x-odata-filter", skip_serializing_if = "Option::is_none")]
236 pub x_odata_filter: Option<ODataPagination<BTreeMap<String, Vec<String>>>>,
237 #[serde(rename = "x-odata-orderby", skip_serializing_if = "Option::is_none")]
238 pub x_odata_orderby: Option<ODataPagination<Vec<String>>>,
239}
240
241#[derive(Clone, Debug, Default, Deserialize, Serialize)]
242pub struct ODataPagination<T> {
243 #[serde(rename = "allowedFields")]
244 pub allowed_fields: T,
245}
246
247#[derive(Clone, Debug, Default)]
249pub struct RateLimitSpec {
250 pub rps: u32,
252 pub burst: u32,
254 pub in_flight: u32,
256}
257
258#[derive(Clone, Debug, Deserialize, Serialize, Default)]
259#[serde(rename_all = "camelCase")]
260pub struct XPagination {
261 pub filter_fields: BTreeMap<String, Vec<String>>,
262 pub order_by: Vec<String>,
263}
264
265pub trait OperationBuilderODataExt<S, H, R> {
267 #[must_use]
269 fn with_odata_filter<T>(self) -> Self
270 where
271 T: toolkit_odata::filter::FilterField;
272
273 #[must_use]
275 fn with_odata_select(self) -> Self;
276
277 #[must_use]
279 fn with_odata_orderby<T>(self) -> Self
280 where
281 T: toolkit_odata::filter::FilterField;
282}
283
284impl<S, H, R, A, L> OperationBuilderODataExt<S, H, R> for OperationBuilder<H, R, S, A, L>
285where
286 H: HandlerSlot<S>,
287 A: AuthState,
288 L: LicenseState,
289{
290 fn with_odata_filter<T>(mut self) -> Self
291 where
292 T: toolkit_odata::filter::FilterField,
293 {
294 use std::fmt::Write as _;
295 use toolkit_odata::filter::FieldKind;
296
297 let mut filter = self
298 .spec
299 .vendor_extensions
300 .x_odata_filter
301 .unwrap_or_default();
302
303 let mut description = "OData v4 filter expression".to_owned();
304 for field in T::FIELDS {
305 let name = field.name().to_owned();
306 let kind = field.kind();
307
308 let ops: Vec<String> = match kind {
309 FieldKind::String => vec!["eq", "ne", "contains", "startswith", "endswith", "in"],
310 FieldKind::Uuid => vec!["eq", "ne", "in"],
311 FieldKind::Bool => vec!["eq", "ne"],
312 FieldKind::I64
313 | FieldKind::F64
314 | FieldKind::Decimal
315 | FieldKind::DateTimeUtc
316 | FieldKind::Date
317 | FieldKind::Time => {
318 vec!["eq", "ne", "gt", "ge", "lt", "le", "in"]
319 }
320 }
321 .into_iter()
322 .map(String::from)
323 .collect();
324
325 _ = write!(description, "\n- {}: {}", name, ops.join("|"));
326 filter.allowed_fields.insert(name.clone(), ops);
327 }
328 self.spec.params.push(ParamSpec {
329 name: "$filter".to_owned(),
330 location: ParamLocation::Query,
331 required: false,
332 description: Some(description),
333 param_type: "string".to_owned(),
334 });
335 self.spec.vendor_extensions.x_odata_filter = Some(filter);
336 self
337 }
338
339 fn with_odata_select(mut self) -> Self {
340 self.spec.params.push(ParamSpec {
341 name: "$select".to_owned(),
342 location: ParamLocation::Query,
343 required: false,
344 description: Some("OData v4 select expression".to_owned()),
345 param_type: "string".to_owned(),
346 });
347 self
348 }
349
350 fn with_odata_orderby<T>(mut self) -> Self
351 where
352 T: toolkit_odata::filter::FilterField,
353 {
354 use std::fmt::Write as _;
355 let mut order_by = self
356 .spec
357 .vendor_extensions
358 .x_odata_orderby
359 .unwrap_or_default();
360 let mut description = "OData v4 orderby expression".to_owned();
361 for field in T::FIELDS {
362 let name = field.name().to_owned();
363
364 let asc = format!("{name} asc");
366 let desc = format!("{name} desc");
367
368 _ = write!(description, "\n- {asc}\n- {desc}");
369 if !order_by.allowed_fields.contains(&asc) {
370 order_by.allowed_fields.push(asc);
371 }
372 if !order_by.allowed_fields.contains(&desc) {
373 order_by.allowed_fields.push(desc);
374 }
375 }
376 self.spec.params.push(ParamSpec {
377 name: "$orderby".to_owned(),
378 location: ParamLocation::Query,
379 required: false,
380 description: Some(description),
381 param_type: "string".to_owned(),
382 });
383 self.spec.vendor_extensions.x_odata_orderby = Some(order_by);
384 self
385 }
386}
387
388pub use crate::api::openapi_registry::{OpenApiRegistry, ensure_schema};
390
391#[must_use]
400pub struct OperationBuilder<H = Missing, R = Missing, S = (), A = AuthNotSet, L = LicenseNotSet>
401where
402 H: HandlerSlot<S>,
403 A: AuthState,
404 L: LicenseState,
405{
406 spec: OperationSpec,
407 method_router: <H as HandlerSlot<S>>::Slot,
408 _has_handler: PhantomData<H>,
409 _has_response: PhantomData<R>,
410 #[allow(clippy::type_complexity)]
411 _state: PhantomData<fn() -> S>, _auth_state: PhantomData<A>,
413 _license_state: PhantomData<L>,
414}
415
416impl<S> OperationBuilder<Missing, Missing, S, AuthNotSet> {
420 pub fn new(method: Method, path: impl Into<String>) -> Self {
422 let path_str = path.into();
423 let handler_id = format!(
424 "{}:{}",
425 method.as_str().to_lowercase(),
426 path_str.replace(['/', '{', '}'], "_")
427 );
428
429 Self {
430 spec: OperationSpec {
431 method,
432 path: path_str,
433 operation_id: None,
434 summary: None,
435 description: None,
436 tags: Vec::new(),
437 params: Vec::new(),
438 request_body: None,
439 responses: Vec::new(),
440 handler_id,
441 authenticated: false,
442 is_public: false,
443 rate_limit: None,
444 allowed_request_content_types: None,
445 vendor_extensions: VendorExtensions::default(),
446 license_requirement: None,
447 },
448 method_router: (), _has_handler: PhantomData,
450 _has_response: PhantomData,
451 _state: PhantomData,
452 _auth_state: PhantomData,
453 _license_state: PhantomData,
454 }
455 }
456
457 pub fn get(path: impl Into<String>) -> Self {
459 let path_str = path.into();
460 Self::new(Method::GET, normalize_to_axum_path(&path_str))
461 }
462
463 pub fn post(path: impl Into<String>) -> Self {
465 let path_str = path.into();
466 Self::new(Method::POST, normalize_to_axum_path(&path_str))
467 }
468
469 pub fn put(path: impl Into<String>) -> Self {
471 let path_str = path.into();
472 Self::new(Method::PUT, normalize_to_axum_path(&path_str))
473 }
474
475 pub fn delete(path: impl Into<String>) -> Self {
477 let path_str = path.into();
478 Self::new(Method::DELETE, normalize_to_axum_path(&path_str))
479 }
480
481 pub fn patch(path: impl Into<String>) -> Self {
483 let path_str = path.into();
484 Self::new(Method::PATCH, normalize_to_axum_path(&path_str))
485 }
486}
487
488impl<H, R, S, A, L> OperationBuilder<H, R, S, A, L>
492where
493 H: HandlerSlot<S>,
494 A: AuthState,
495 L: LicenseState,
496{
497 pub fn spec(&self) -> &OperationSpec {
499 &self.spec
500 }
501
502 pub fn operation_id(mut self, id: impl Into<String>) -> Self {
504 self.spec.operation_id = Some(id.into());
505 self
506 }
507
508 pub fn require_rate_limit(&mut self, rps: u32, burst: u32, in_flight: u32) -> &mut Self {
511 self.spec.rate_limit = Some(RateLimitSpec {
512 rps,
513 burst,
514 in_flight,
515 });
516 self
517 }
518
519 pub fn summary(mut self, text: impl Into<String>) -> Self {
521 self.spec.summary = Some(text.into());
522 self
523 }
524
525 pub fn description(mut self, text: impl Into<String>) -> Self {
527 self.spec.description = Some(text.into());
528 self
529 }
530
531 pub fn tag(mut self, tag: impl Into<String>) -> Self {
533 self.spec.tags.push(tag.into());
534 self
535 }
536
537 pub fn param(mut self, param: ParamSpec) -> Self {
539 self.spec.params.push(param);
540 self
541 }
542
543 pub fn path_param(mut self, name: impl Into<String>, description: impl Into<String>) -> Self {
545 self.spec.params.push(ParamSpec {
546 name: name.into(),
547 location: ParamLocation::Path,
548 required: true,
549 description: Some(description.into()),
550 param_type: "string".to_owned(),
551 });
552 self
553 }
554
555 pub fn query_param(
557 mut self,
558 name: impl Into<String>,
559 required: bool,
560 description: impl Into<String>,
561 ) -> Self {
562 self.spec.params.push(ParamSpec {
563 name: name.into(),
564 location: ParamLocation::Query,
565 required,
566 description: Some(description.into()),
567 param_type: "string".to_owned(),
568 });
569 self
570 }
571
572 pub fn query_param_typed(
574 mut self,
575 name: impl Into<String>,
576 required: bool,
577 description: impl Into<String>,
578 param_type: impl Into<String>,
579 ) -> Self {
580 self.spec.params.push(ParamSpec {
581 name: name.into(),
582 location: ParamLocation::Query,
583 required,
584 description: Some(description.into()),
585 param_type: param_type.into(),
586 });
587 self
588 }
589
590 pub fn json_request_schema(
593 mut self,
594 schema_name: impl Into<String>,
595 desc: impl Into<String>,
596 ) -> Self {
597 self.spec.request_body = Some(RequestBodySpec {
598 content_type: "application/json",
599 description: Some(desc.into()),
600 schema: RequestBodySchema::Ref {
601 schema_name: schema_name.into(),
602 },
603 required: true,
604 });
605 self
606 }
607
608 pub fn json_request_schema_no_desc(mut self, schema_name: impl Into<String>) -> Self {
611 self.spec.request_body = Some(RequestBodySpec {
612 content_type: "application/json",
613 description: None,
614 schema: RequestBodySchema::Ref {
615 schema_name: schema_name.into(),
616 },
617 required: true,
618 });
619 self
620 }
621
622 pub fn json_request<T>(
625 mut self,
626 registry: &dyn OpenApiRegistry,
627 desc: impl Into<String>,
628 ) -> Self
629 where
630 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::RequestApiDto + 'static,
631 {
632 let name = ensure_schema::<T>(registry);
633 self.spec.request_body = Some(RequestBodySpec {
634 content_type: "application/json",
635 description: Some(desc.into()),
636 schema: RequestBodySchema::Ref { schema_name: name },
637 required: true,
638 });
639 self
640 }
641
642 pub fn json_request_no_desc<T>(mut self, registry: &dyn OpenApiRegistry) -> Self
645 where
646 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::RequestApiDto + 'static,
647 {
648 let name = ensure_schema::<T>(registry);
649 self.spec.request_body = Some(RequestBodySpec {
650 content_type: "application/json",
651 description: None,
652 schema: RequestBodySchema::Ref { schema_name: name },
653 required: true,
654 });
655 self
656 }
657
658 pub fn request_optional(mut self) -> Self {
660 if let Some(rb) = &mut self.spec.request_body {
661 rb.required = false;
662 }
663 self
664 }
665
666 pub fn multipart_file_request(mut self, field_name: &str, description: Option<&str>) -> Self {
704 self.spec.request_body = Some(RequestBodySpec {
706 content_type: "multipart/form-data",
707 description: description
708 .map(|s| format!("{s} (expects field '{field_name}' with file data)")),
709 schema: RequestBodySchema::MultipartFile {
710 field_name: field_name.to_owned(),
711 },
712 required: true,
713 });
714
715 self.spec.allowed_request_content_types = Some(vec!["multipart/form-data"]);
717
718 self
719 }
720
721 pub fn octet_stream_request(mut self, description: Option<&str>) -> Self {
765 self.spec.request_body = Some(RequestBodySpec {
766 content_type: "application/octet-stream",
767 description: description.map(str::to_owned),
768 schema: RequestBodySchema::Binary,
769 required: true,
770 });
771
772 self.spec.allowed_request_content_types = Some(vec!["application/octet-stream"]);
774
775 self
776 }
777
778 pub fn allow_content_types(mut self, types: &[&'static str]) -> Self {
808 self.spec.allowed_request_content_types = Some(types.to_vec());
809 self
810 }
811}
812
813impl<H, R, S> OperationBuilder<H, R, S, AuthSet, LicenseNotSet>
815where
816 H: HandlerSlot<S>,
817{
818 pub fn require_license_features<F>(
831 mut self,
832 licenses: impl IntoIterator<Item = F>,
833 ) -> OperationBuilder<H, R, S, AuthSet, LicenseSet>
834 where
835 F: LicenseFeature,
836 {
837 let license_names: Vec<String> = licenses
838 .into_iter()
839 .map(|l| l.as_ref().to_owned())
840 .collect();
841
842 self.spec.license_requirement =
843 (!license_names.is_empty()).then_some(LicenseReqSpec { license_names });
844
845 OperationBuilder {
846 spec: self.spec,
847 method_router: self.method_router,
848 _has_handler: self._has_handler,
849 _has_response: self._has_response,
850 _state: self._state,
851 _auth_state: self._auth_state,
852 _license_state: PhantomData,
853 }
854 }
855
856 pub fn no_license_required(self) -> OperationBuilder<H, R, S, AuthSet, LicenseSet> {
864 OperationBuilder {
865 spec: self.spec,
866 method_router: self.method_router,
867 _has_handler: self._has_handler,
868 _has_response: self._has_response,
869 _state: self._state,
870 _auth_state: self._auth_state,
871 _license_state: PhantomData,
872 }
873 }
874}
875
876impl<H, R, S, L> OperationBuilder<H, R, S, AuthNotSet, L>
880where
881 H: HandlerSlot<S>,
882 L: LicenseState,
883{
884 pub fn authenticated(mut self) -> OperationBuilder<H, R, S, AuthSet, L> {
934 self.spec.authenticated = true;
935 self.spec.is_public = false;
936 OperationBuilder {
937 spec: self.spec,
938 method_router: self.method_router,
939 _has_handler: self._has_handler,
940 _has_response: self._has_response,
941 _state: self._state,
942 _auth_state: PhantomData,
943 _license_state: self._license_state,
944 }
945 }
946
947 pub fn public(mut self) -> OperationBuilder<H, R, S, AuthSet, LicenseSet> {
971 self.spec.is_public = true;
972 self.spec.authenticated = false;
973 OperationBuilder {
974 spec: self.spec,
975 method_router: self.method_router,
976 _has_handler: self._has_handler,
977 _has_response: self._has_response,
978 _state: self._state,
979 _auth_state: PhantomData,
980 _license_state: PhantomData,
981 }
982 }
983}
984
985impl<R, S, A, L> OperationBuilder<Missing, R, S, A, L>
989where
990 S: Clone + Send + Sync + 'static,
991 A: AuthState,
992 L: LicenseState,
993{
994 pub fn handler<F, T>(self, h: F) -> OperationBuilder<Present, R, S, A, L>
998 where
999 F: Handler<T, S> + Clone + Send + 'static,
1000 T: 'static,
1001 {
1002 let method_router = match self.spec.method {
1003 Method::GET => axum::routing::get(h),
1004 Method::POST => axum::routing::post(h),
1005 Method::PUT => axum::routing::put(h),
1006 Method::DELETE => axum::routing::delete(h),
1007 Method::PATCH => axum::routing::patch(h),
1008 _ => axum::routing::any(|| async { axum::http::StatusCode::METHOD_NOT_ALLOWED }),
1009 };
1010
1011 OperationBuilder {
1012 spec: self.spec,
1013 method_router, _has_handler: PhantomData::<Present>,
1015 _has_response: self._has_response,
1016 _state: self._state,
1017 _auth_state: self._auth_state,
1018 _license_state: self._license_state,
1019 }
1020 }
1021
1022 pub fn method_router(self, mr: MethodRouter<S>) -> OperationBuilder<Present, R, S, A, L> {
1025 OperationBuilder {
1026 spec: self.spec,
1027 method_router: mr, _has_handler: PhantomData::<Present>,
1029 _has_response: self._has_response,
1030 _state: self._state,
1031 _auth_state: self._auth_state,
1032 _license_state: self._license_state,
1033 }
1034 }
1035}
1036
1037impl<H, S, A, L> OperationBuilder<H, Missing, S, A, L>
1041where
1042 H: HandlerSlot<S>,
1043 A: AuthState,
1044 L: LicenseState,
1045{
1046 pub fn response(mut self, resp: ResponseSpec) -> OperationBuilder<H, Present, S, A, L> {
1048 self.spec.responses.push(resp);
1049 OperationBuilder {
1050 spec: self.spec,
1051 method_router: self.method_router,
1052 _has_handler: self._has_handler,
1053 _has_response: PhantomData::<Present>,
1054 _state: self._state,
1055 _auth_state: self._auth_state,
1056 _license_state: self._license_state,
1057 }
1058 }
1059
1060 pub fn json_response(
1062 mut self,
1063 status: http::StatusCode,
1064 description: impl Into<String>,
1065 ) -> OperationBuilder<H, Present, S, A, L> {
1066 self.spec.responses.push(ResponseSpec {
1067 status: status.as_u16(),
1068 content_type: "application/json",
1069 description: description.into(),
1070 schema_name: None,
1071 });
1072 OperationBuilder {
1073 spec: self.spec,
1074 method_router: self.method_router,
1075 _has_handler: self._has_handler,
1076 _has_response: PhantomData::<Present>,
1077 _state: self._state,
1078 _auth_state: self._auth_state,
1079 _license_state: self._license_state,
1080 }
1081 }
1082
1083 pub fn no_content_response(
1091 mut self,
1092 status: http::StatusCode,
1093 description: impl Into<String>,
1094 ) -> OperationBuilder<H, Present, S, A, L> {
1095 self.spec.responses.push(ResponseSpec {
1096 status: status.as_u16(),
1097 content_type: "",
1098 description: description.into(),
1099 schema_name: None,
1100 });
1101 OperationBuilder {
1102 spec: self.spec,
1103 method_router: self.method_router,
1104 _has_handler: self._has_handler,
1105 _has_response: PhantomData::<Present>,
1106 _state: self._state,
1107 _auth_state: self._auth_state,
1108 _license_state: self._license_state,
1109 }
1110 }
1111
1112 pub fn json_response_with_schema<T>(
1114 mut self,
1115 registry: &dyn OpenApiRegistry,
1116 status: http::StatusCode,
1117 description: impl Into<String>,
1118 ) -> OperationBuilder<H, Present, S, A, L>
1119 where
1120 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
1121 {
1122 let name = ensure_schema::<T>(registry);
1123 self.spec.responses.push(ResponseSpec {
1124 status: status.as_u16(),
1125 content_type: "application/json",
1126 description: description.into(),
1127 schema_name: Some(name),
1128 });
1129 OperationBuilder {
1130 spec: self.spec,
1131 method_router: self.method_router,
1132 _has_handler: self._has_handler,
1133 _has_response: PhantomData::<Present>,
1134 _state: self._state,
1135 _auth_state: self._auth_state,
1136 _license_state: self._license_state,
1137 }
1138 }
1139
1140 pub fn text_response(
1153 mut self,
1154 status: http::StatusCode,
1155 description: impl Into<String>,
1156 content_type: &'static str,
1157 ) -> OperationBuilder<H, Present, S, A, L> {
1158 self.spec.responses.push(ResponseSpec {
1159 status: status.as_u16(),
1160 content_type,
1161 description: description.into(),
1162 schema_name: None,
1163 });
1164 OperationBuilder {
1165 spec: self.spec,
1166 method_router: self.method_router,
1167 _has_handler: self._has_handler,
1168 _has_response: PhantomData::<Present>,
1169 _state: self._state,
1170 _auth_state: self._auth_state,
1171 _license_state: self._license_state,
1172 }
1173 }
1174
1175 pub fn html_response(
1177 mut self,
1178 status: http::StatusCode,
1179 description: impl Into<String>,
1180 ) -> OperationBuilder<H, Present, S, A, L> {
1181 self.spec.responses.push(ResponseSpec {
1182 status: status.as_u16(),
1183 content_type: "text/html",
1184 description: description.into(),
1185 schema_name: None,
1186 });
1187 OperationBuilder {
1188 spec: self.spec,
1189 method_router: self.method_router,
1190 _has_handler: self._has_handler,
1191 _has_response: PhantomData::<Present>,
1192 _state: self._state,
1193 _auth_state: self._auth_state,
1194 _license_state: self._license_state,
1195 }
1196 }
1197
1198 pub fn problem_response(
1200 mut self,
1201 registry: &dyn OpenApiRegistry,
1202 status: http::StatusCode,
1203 description: impl Into<String>,
1204 ) -> OperationBuilder<H, Present, S, A, L> {
1205 let problem_name = ensure_schema::<toolkit_canonical_errors::Problem>(registry);
1207 self.spec.responses.push(ResponseSpec {
1208 status: status.as_u16(),
1209 content_type: problem::APPLICATION_PROBLEM_JSON,
1210 description: description.into(),
1211 schema_name: Some(problem_name),
1212 });
1213 OperationBuilder {
1214 spec: self.spec,
1215 method_router: self.method_router,
1216 _has_handler: self._has_handler,
1217 _has_response: PhantomData::<Present>,
1218 _state: self._state,
1219 _auth_state: self._auth_state,
1220 _license_state: self._license_state,
1221 }
1222 }
1223
1224 pub fn sse_json<T>(
1226 mut self,
1227 openapi: &dyn OpenApiRegistry,
1228 description: impl Into<String>,
1229 ) -> OperationBuilder<H, Present, S, A, L>
1230 where
1231 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
1232 {
1233 let name = ensure_schema::<T>(openapi);
1234 self.spec.responses.push(ResponseSpec {
1235 status: http::StatusCode::OK.as_u16(),
1236 content_type: "text/event-stream",
1237 description: description.into(),
1238 schema_name: Some(name),
1239 });
1240 OperationBuilder {
1241 spec: self.spec,
1242 method_router: self.method_router,
1243 _has_handler: self._has_handler,
1244 _has_response: PhantomData::<Present>,
1245 _state: self._state,
1246 _auth_state: self._auth_state,
1247 _license_state: self._license_state,
1248 }
1249 }
1250}
1251
1252impl<H, S, A, L> OperationBuilder<H, Present, S, A, L>
1256where
1257 H: HandlerSlot<S>,
1258 A: AuthState,
1259 L: LicenseState,
1260{
1261 pub fn json_response(
1263 mut self,
1264 status: http::StatusCode,
1265 description: impl Into<String>,
1266 ) -> Self {
1267 self.spec.responses.push(ResponseSpec {
1268 status: status.as_u16(),
1269 content_type: "application/json",
1270 description: description.into(),
1271 schema_name: None,
1272 });
1273 self
1274 }
1275
1276 pub fn no_content_response(
1278 mut self,
1279 status: http::StatusCode,
1280 description: impl Into<String>,
1281 ) -> Self {
1282 self.spec.responses.push(ResponseSpec {
1283 status: status.as_u16(),
1284 content_type: "",
1285 description: description.into(),
1286 schema_name: None,
1287 });
1288 self
1289 }
1290
1291 pub fn json_response_with_schema<T>(
1293 mut self,
1294 registry: &dyn OpenApiRegistry,
1295 status: http::StatusCode,
1296 description: impl Into<String>,
1297 ) -> Self
1298 where
1299 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
1300 {
1301 let name = ensure_schema::<T>(registry);
1302 self.spec.responses.push(ResponseSpec {
1303 status: status.as_u16(),
1304 content_type: "application/json",
1305 description: description.into(),
1306 schema_name: Some(name),
1307 });
1308 self
1309 }
1310
1311 pub fn text_response(
1324 mut self,
1325 status: http::StatusCode,
1326 description: impl Into<String>,
1327 content_type: &'static str,
1328 ) -> Self {
1329 self.spec.responses.push(ResponseSpec {
1330 status: status.as_u16(),
1331 content_type,
1332 description: description.into(),
1333 schema_name: None,
1334 });
1335 self
1336 }
1337
1338 pub fn html_response(
1340 mut self,
1341 status: http::StatusCode,
1342 description: impl Into<String>,
1343 ) -> Self {
1344 self.spec.responses.push(ResponseSpec {
1345 status: status.as_u16(),
1346 content_type: "text/html",
1347 description: description.into(),
1348 schema_name: None,
1349 });
1350 self
1351 }
1352
1353 pub fn problem_response(
1355 mut self,
1356 registry: &dyn OpenApiRegistry,
1357 status: http::StatusCode,
1358 description: impl Into<String>,
1359 ) -> Self {
1360 let problem_name = ensure_schema::<toolkit_canonical_errors::Problem>(registry);
1362 self.spec.responses.push(ResponseSpec {
1363 status: status.as_u16(),
1364 content_type: problem::APPLICATION_PROBLEM_JSON,
1365 description: description.into(),
1366 schema_name: Some(problem_name),
1367 });
1368 self
1369 }
1370
1371 pub fn sse_json<T>(
1373 mut self,
1374 openapi: &dyn OpenApiRegistry,
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>(openapi);
1381 self.spec.responses.push(ResponseSpec {
1382 status: http::StatusCode::OK.as_u16(),
1383 content_type: "text/event-stream",
1384 description: description.into(),
1385 schema_name: Some(name),
1386 });
1387 self
1388 }
1389
1390 pub fn standard_errors(mut self, registry: &dyn OpenApiRegistry) -> Self {
1432 use http::StatusCode;
1433 let problem_name = ensure_schema::<toolkit_canonical_errors::Problem>(registry);
1435
1436 let standard_errors = [
1437 (StatusCode::BAD_REQUEST, "Bad Request"),
1438 (StatusCode::UNAUTHORIZED, "Unauthorized"),
1439 (StatusCode::FORBIDDEN, "Forbidden"),
1440 (StatusCode::NOT_FOUND, "Not Found"),
1441 (StatusCode::CONFLICT, "Conflict"),
1442 (StatusCode::TOO_MANY_REQUESTS, "Too Many Requests"),
1443 (StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error"),
1444 ];
1445
1446 for (status, description) in standard_errors {
1447 self.spec.responses.push(ResponseSpec {
1448 status: status.as_u16(),
1449 content_type: problem::APPLICATION_PROBLEM_JSON,
1450 description: description.to_owned(),
1451 schema_name: Some(problem_name.clone()),
1452 });
1453 }
1454
1455 self
1456 }
1457
1458 pub fn with_400_validation_error(mut self, registry: &dyn OpenApiRegistry) -> Self {
1495 let problem_name = ensure_schema::<toolkit_canonical_errors::Problem>(registry);
1496
1497 self.spec.responses.push(ResponseSpec {
1498 status: http::StatusCode::BAD_REQUEST.as_u16(),
1499 content_type: problem::APPLICATION_PROBLEM_JSON,
1500 description: "Validation Error".to_owned(),
1501 schema_name: Some(problem_name),
1502 });
1503
1504 self
1505 }
1506
1507 pub fn error_400(self, registry: &dyn OpenApiRegistry) -> Self {
1511 self.problem_response(registry, http::StatusCode::BAD_REQUEST, "Bad Request")
1512 }
1513
1514 pub fn error_401(self, registry: &dyn OpenApiRegistry) -> Self {
1518 self.problem_response(registry, http::StatusCode::UNAUTHORIZED, "Unauthorized")
1519 }
1520
1521 pub fn error_403(self, registry: &dyn OpenApiRegistry) -> Self {
1525 self.problem_response(registry, http::StatusCode::FORBIDDEN, "Forbidden")
1526 }
1527
1528 pub fn error_404(self, registry: &dyn OpenApiRegistry) -> Self {
1532 self.problem_response(registry, http::StatusCode::NOT_FOUND, "Not Found")
1533 }
1534
1535 pub fn error_409(self, registry: &dyn OpenApiRegistry) -> Self {
1539 self.problem_response(registry, http::StatusCode::CONFLICT, "Conflict")
1540 }
1541
1542 pub fn error_415(self, registry: &dyn OpenApiRegistry) -> Self {
1546 self.problem_response(
1547 registry,
1548 http::StatusCode::UNSUPPORTED_MEDIA_TYPE,
1549 "Unsupported Media Type",
1550 )
1551 }
1552
1553 pub fn error_422(self, registry: &dyn OpenApiRegistry) -> Self {
1557 self.problem_response(
1558 registry,
1559 http::StatusCode::UNPROCESSABLE_ENTITY,
1560 "Unprocessable Entity",
1561 )
1562 }
1563
1564 pub fn error_429(self, registry: &dyn OpenApiRegistry) -> Self {
1568 self.problem_response(
1569 registry,
1570 http::StatusCode::TOO_MANY_REQUESTS,
1571 "Too Many Requests",
1572 )
1573 }
1574
1575 pub fn error_500(self, registry: &dyn OpenApiRegistry) -> Self {
1579 self.problem_response(
1580 registry,
1581 http::StatusCode::INTERNAL_SERVER_ERROR,
1582 "Internal Server Error",
1583 )
1584 }
1585
1586 pub fn error_502(self, registry: &dyn OpenApiRegistry) -> Self {
1590 self.problem_response(registry, http::StatusCode::BAD_GATEWAY, "Bad Gateway")
1591 }
1592
1593 pub fn error_503(self, registry: &dyn OpenApiRegistry) -> Self {
1597 self.problem_response(
1598 registry,
1599 http::StatusCode::SERVICE_UNAVAILABLE,
1600 "Service Unavailable",
1601 )
1602 }
1603
1604 pub fn error_504(self, registry: &dyn OpenApiRegistry) -> Self {
1608 self.problem_response(
1609 registry,
1610 http::StatusCode::GATEWAY_TIMEOUT,
1611 "Gateway Timeout",
1612 )
1613 }
1614}
1615
1616impl<S> OperationBuilder<Present, Present, S, AuthSet, LicenseSet>
1620where
1621 S: Clone + Send + Sync + 'static,
1622{
1623 pub fn register(self, router: Router<S>, openapi: &dyn OpenApiRegistry) -> Router<S> {
1632 openapi.register_operation(&self.spec);
1635
1636 router.route(&self.spec.path, self.method_router)
1638 }
1639}
1640
1641#[cfg(test)]
1645#[cfg_attr(coverage_nightly, coverage(off))]
1646mod tests {
1647 use super::*;
1648 use axum::Json;
1649
1650 struct MockRegistry {
1652 operations: std::sync::Mutex<Vec<OperationSpec>>,
1653 schemas: std::sync::Mutex<Vec<String>>,
1654 }
1655
1656 impl MockRegistry {
1657 fn new() -> Self {
1658 Self {
1659 operations: std::sync::Mutex::new(Vec::new()),
1660 schemas: std::sync::Mutex::new(Vec::new()),
1661 }
1662 }
1663 }
1664
1665 enum TestLicenseFeatures {
1666 FeatureA,
1667 FeatureB,
1668 }
1669 impl AsRef<str> for TestLicenseFeatures {
1670 fn as_ref(&self) -> &str {
1671 match self {
1672 TestLicenseFeatures::FeatureA => "feature_a",
1673 TestLicenseFeatures::FeatureB => "feature_b",
1674 }
1675 }
1676 }
1677 impl LicenseFeature for TestLicenseFeatures {}
1678
1679 impl OpenApiRegistry for MockRegistry {
1680 fn register_operation(&self, spec: &OperationSpec) {
1681 if let Ok(mut ops) = self.operations.lock() {
1682 ops.push(spec.clone());
1683 }
1684 }
1685
1686 fn ensure_schema_raw(
1687 &self,
1688 name: &str,
1689 _schemas: Vec<(
1690 String,
1691 utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>,
1692 )>,
1693 ) -> String {
1694 let name = name.to_owned();
1695 if let Ok(mut s) = self.schemas.lock() {
1696 s.push(name.clone());
1697 }
1698 name
1699 }
1700
1701 fn as_any(&self) -> &dyn std::any::Any {
1702 self
1703 }
1704 }
1705
1706 async fn test_handler() -> Json<serde_json::Value> {
1707 Json(serde_json::json!({"status": "ok"}))
1708 }
1709
1710 #[toolkit_macros::api_dto(request)]
1711 struct SampleDtoRequest;
1712
1713 #[toolkit_macros::api_dto(response)]
1714 struct SampleDtoResponse;
1715
1716 #[test]
1717 fn builder_descriptive_methods() {
1718 let builder = OperationBuilder::<Missing, Missing, (), AuthNotSet>::get("/tests/v1/test")
1719 .operation_id("test.get")
1720 .summary("Test endpoint")
1721 .description("A test endpoint for validation")
1722 .tag("test")
1723 .path_param("id", "Test ID");
1724
1725 assert_eq!(builder.spec.method, Method::GET);
1726 assert_eq!(builder.spec.path, "/tests/v1/test");
1727 assert_eq!(builder.spec.operation_id, Some("test.get".to_owned()));
1728 assert_eq!(builder.spec.summary, Some("Test endpoint".to_owned()));
1729 assert_eq!(
1730 builder.spec.description,
1731 Some("A test endpoint for validation".to_owned())
1732 );
1733 assert_eq!(builder.spec.tags, vec!["test"]);
1734 assert_eq!(builder.spec.params.len(), 1);
1735 }
1736
1737 #[tokio::test]
1738 async fn builder_with_request_response_and_handler() {
1739 let registry = MockRegistry::new();
1740 let router = Router::new();
1741
1742 let _router = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
1743 .summary("Test endpoint")
1744 .json_request::<SampleDtoRequest>(®istry, "optional body") .public()
1746 .handler(test_handler)
1747 .json_response_with_schema::<SampleDtoResponse>(
1748 ®istry,
1749 http::StatusCode::OK,
1750 "Success response",
1751 ) .register(router, ®istry);
1753
1754 let ops = registry.operations.lock().unwrap();
1756 assert_eq!(ops.len(), 1);
1757 let op = &ops[0];
1758 assert_eq!(op.method, Method::POST);
1759 assert_eq!(op.path, "/tests/v1/test");
1760 assert!(op.request_body.is_some());
1761 assert!(op.request_body.as_ref().unwrap().required);
1762 assert_eq!(op.responses.len(), 1);
1763 assert_eq!(op.responses[0].status, 200);
1764
1765 let schemas = registry.schemas.lock().unwrap();
1767 assert!(!schemas.is_empty());
1768 }
1769
1770 #[test]
1771 fn convenience_constructors() {
1772 let get_builder =
1773 OperationBuilder::<Missing, Missing, (), AuthNotSet>::get("/tests/v1/get");
1774 assert_eq!(get_builder.spec.method, Method::GET);
1775 assert_eq!(get_builder.spec.path, "/tests/v1/get");
1776
1777 let post_builder =
1778 OperationBuilder::<Missing, Missing, (), AuthNotSet>::post("/tests/v1/post");
1779 assert_eq!(post_builder.spec.method, Method::POST);
1780 assert_eq!(post_builder.spec.path, "/tests/v1/post");
1781
1782 let put_builder =
1783 OperationBuilder::<Missing, Missing, (), AuthNotSet>::put("/tests/v1/put");
1784 assert_eq!(put_builder.spec.method, Method::PUT);
1785 assert_eq!(put_builder.spec.path, "/tests/v1/put");
1786
1787 let delete_builder =
1788 OperationBuilder::<Missing, Missing, (), AuthNotSet>::delete("/tests/v1/delete");
1789 assert_eq!(delete_builder.spec.method, Method::DELETE);
1790 assert_eq!(delete_builder.spec.path, "/tests/v1/delete");
1791
1792 let patch_builder =
1793 OperationBuilder::<Missing, Missing, (), AuthNotSet>::patch("/tests/v1/patch");
1794 assert_eq!(patch_builder.spec.method, Method::PATCH);
1795 assert_eq!(patch_builder.spec.path, "/tests/v1/patch");
1796 }
1797
1798 #[test]
1799 fn normalize_to_axum_path_should_normalize() {
1800 assert_eq!(
1802 normalize_to_axum_path("/tests/v1/users/{id}"),
1803 "/tests/v1/users/{id}"
1804 );
1805 assert_eq!(
1806 normalize_to_axum_path("/tests/v1/projects/{project_id}/items/{item_id}"),
1807 "/tests/v1/projects/{project_id}/items/{item_id}"
1808 );
1809 assert_eq!(
1810 normalize_to_axum_path("/tests/v1/simple"),
1811 "/tests/v1/simple"
1812 );
1813 assert_eq!(
1814 normalize_to_axum_path("/tests/v1/users/{id}/edit"),
1815 "/tests/v1/users/{id}/edit"
1816 );
1817 }
1818
1819 #[test]
1820 fn axum_to_openapi_path_should_convert() {
1821 assert_eq!(
1823 axum_to_openapi_path("/tests/v1/users/{id}"),
1824 "/tests/v1/users/{id}"
1825 );
1826 assert_eq!(
1827 axum_to_openapi_path("/tests/v1/projects/{project_id}/items/{item_id}"),
1828 "/tests/v1/projects/{project_id}/items/{item_id}"
1829 );
1830 assert_eq!(axum_to_openapi_path("/tests/v1/simple"), "/tests/v1/simple");
1831 assert_eq!(
1833 axum_to_openapi_path("/tests/v1/static/{*path}"),
1834 "/tests/v1/static/{path}"
1835 );
1836 assert_eq!(
1837 axum_to_openapi_path("/tests/v1/files/{*filepath}"),
1838 "/tests/v1/files/{filepath}"
1839 );
1840 }
1841
1842 #[test]
1843 fn path_normalization_in_constructors() {
1844 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/users/{id}");
1846 assert_eq!(builder.spec.path, "/tests/v1/users/{id}");
1847
1848 let builder = OperationBuilder::<Missing, Missing, ()>::post(
1849 "/tests/v1/projects/{project_id}/items/{item_id}",
1850 );
1851 assert_eq!(
1852 builder.spec.path,
1853 "/tests/v1/projects/{project_id}/items/{item_id}"
1854 );
1855
1856 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/simple");
1858 assert_eq!(builder.spec.path, "/tests/v1/simple");
1859 }
1860
1861 #[test]
1862 fn standard_errors() {
1863 let registry = MockRegistry::new();
1864 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1865 .public()
1866 .handler(test_handler)
1867 .json_response(http::StatusCode::OK, "Success")
1868 .standard_errors(®istry);
1869
1870 assert_eq!(builder.spec.responses.len(), 8);
1873
1874 let statuses: Vec<u16> = builder.spec.responses.iter().map(|r| r.status).collect();
1876 assert!(statuses.contains(&200)); assert!(statuses.contains(&400));
1878 assert!(statuses.contains(&401));
1879 assert!(statuses.contains(&403));
1880 assert!(statuses.contains(&404));
1881 assert!(statuses.contains(&409));
1882 assert!(!statuses.contains(&422));
1883 assert!(statuses.contains(&429));
1884 assert!(statuses.contains(&500));
1885
1886 let error_responses: Vec<_> = builder
1888 .spec
1889 .responses
1890 .iter()
1891 .filter(|r| r.status >= 400)
1892 .collect();
1893
1894 for resp in error_responses {
1895 assert_eq!(
1896 resp.content_type,
1897 toolkit_canonical_errors::problem::APPLICATION_PROBLEM_JSON
1898 );
1899 assert!(resp.schema_name.is_some());
1900 }
1901 }
1902
1903 #[test]
1904 fn authenticated() {
1905 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1906 .authenticated()
1907 .handler(test_handler)
1908 .json_response(http::StatusCode::OK, "Success");
1909
1910 assert!(builder.spec.authenticated);
1911 assert!(!builder.spec.is_public);
1912 }
1913
1914 #[test]
1915 fn require_license_features_none() {
1916 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1917 .authenticated()
1918 .require_license_features::<TestLicenseFeatures>([])
1919 .handler(|| async {})
1920 .json_response(http::StatusCode::OK, "OK");
1921
1922 assert!(builder.spec.license_requirement.is_none());
1923 }
1924
1925 #[test]
1926 fn no_license_required_transitions_and_allows_register() {
1927 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1928 .authenticated()
1929 .no_license_required()
1930 .handler(|| async {})
1931 .json_response(http::StatusCode::OK, "OK");
1932
1933 assert!(builder.spec.license_requirement.is_none());
1934 assert!(!builder.spec.is_public);
1935 }
1936
1937 #[test]
1938 fn require_license_features_one() {
1939 let feature = TestLicenseFeatures::FeatureA;
1940
1941 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1942 .authenticated()
1943 .require_license_features([&feature])
1944 .handler(|| async {})
1945 .json_response(http::StatusCode::OK, "OK");
1946
1947 let license_req = builder
1948 .spec
1949 .license_requirement
1950 .as_ref()
1951 .expect("Should have license requirement");
1952 assert_eq!(license_req.license_names, vec!["feature_a".to_owned()]);
1953 }
1954
1955 #[test]
1956 fn require_license_features_many() {
1957 let feature_a = TestLicenseFeatures::FeatureA;
1958 let feature_b = TestLicenseFeatures::FeatureB;
1959
1960 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1961 .authenticated()
1962 .require_license_features([&feature_a, &feature_b])
1963 .handler(|| async {})
1964 .json_response(http::StatusCode::OK, "OK");
1965
1966 let license_req = builder
1967 .spec
1968 .license_requirement
1969 .as_ref()
1970 .expect("Should have license requirement");
1971 assert_eq!(
1972 license_req.license_names,
1973 vec!["feature_a".to_owned(), "feature_b".to_owned()]
1974 );
1975 }
1976
1977 #[tokio::test]
1978 async fn public_does_not_require_license_features_and_can_register() {
1979 let registry = MockRegistry::new();
1980 let router = Router::new();
1981
1982 let _router = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1983 .public()
1984 .handler(test_handler)
1985 .json_response(http::StatusCode::OK, "Success")
1986 .register(router, ®istry);
1987
1988 let ops = registry.operations.lock().unwrap();
1989 assert_eq!(ops.len(), 1);
1990 assert!(ops[0].license_requirement.is_none());
1991 }
1992
1993 #[test]
1994 fn with_400_validation_error() {
1995 let registry = MockRegistry::new();
1996 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
1997 .public()
1998 .handler(test_handler)
1999 .json_response(http::StatusCode::CREATED, "Created")
2000 .with_400_validation_error(®istry);
2001
2002 assert_eq!(builder.spec.responses.len(), 2);
2004
2005 let validation_response = builder
2006 .spec
2007 .responses
2008 .iter()
2009 .find(|r| r.status == 400)
2010 .expect("Should have 400 response");
2011
2012 assert_eq!(validation_response.description, "Validation Error");
2013 assert_eq!(
2014 validation_response.content_type,
2015 toolkit_canonical_errors::problem::APPLICATION_PROBLEM_JSON
2016 );
2017 assert!(validation_response.schema_name.is_some());
2018 }
2019
2020 #[test]
2021 fn allow_content_types_with_existing_request_body() {
2022 let registry = MockRegistry::new();
2023 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
2024 .json_request::<SampleDtoRequest>(®istry, "Test request")
2025 .allow_content_types(&["application/json", "application/xml"])
2026 .public()
2027 .handler(test_handler)
2028 .json_response(http::StatusCode::OK, "Success");
2029
2030 assert!(builder.spec.request_body.is_some());
2032 assert!(builder.spec.allowed_request_content_types.is_some());
2033 let allowed = builder.spec.allowed_request_content_types.as_ref().unwrap();
2034 assert_eq!(allowed.len(), 2);
2035 assert!(allowed.contains(&"application/json"));
2036 assert!(allowed.contains(&"application/xml"));
2037 }
2038
2039 #[test]
2040 fn allow_content_types_without_existing_request_body() {
2041 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
2042 .allow_content_types(&["multipart/form-data"])
2043 .public()
2044 .handler(test_handler)
2045 .json_response(http::StatusCode::OK, "Success");
2046
2047 assert!(builder.spec.request_body.is_none());
2049 assert!(builder.spec.allowed_request_content_types.is_some());
2050 let allowed = builder.spec.allowed_request_content_types.as_ref().unwrap();
2051 assert_eq!(allowed.len(), 1);
2052 assert!(allowed.contains(&"multipart/form-data"));
2053 }
2054
2055 #[test]
2056 fn allow_content_types_can_be_chained() {
2057 let registry = MockRegistry::new();
2058 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
2059 .operation_id("test.post")
2060 .summary("Test endpoint")
2061 .json_request::<SampleDtoRequest>(®istry, "Test request")
2062 .allow_content_types(&["application/json"])
2063 .public()
2064 .handler(test_handler)
2065 .json_response(http::StatusCode::OK, "Success")
2066 .problem_response(
2067 ®istry,
2068 http::StatusCode::UNSUPPORTED_MEDIA_TYPE,
2069 "Unsupported Media Type",
2070 );
2071
2072 assert_eq!(builder.spec.operation_id, Some("test.post".to_owned()));
2073 assert!(builder.spec.request_body.is_some());
2074 assert!(builder.spec.allowed_request_content_types.is_some());
2075 assert_eq!(builder.spec.responses.len(), 2);
2076 }
2077
2078 #[test]
2079 fn multipart_file_request() {
2080 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/upload")
2081 .operation_id("test.upload")
2082 .summary("Upload file")
2083 .multipart_file_request("file", Some("Upload a file"))
2084 .public()
2085 .handler(test_handler)
2086 .json_response(http::StatusCode::OK, "Success");
2087
2088 assert!(builder.spec.request_body.is_some());
2090 let rb = builder.spec.request_body.as_ref().unwrap();
2091 assert_eq!(rb.content_type, "multipart/form-data");
2092 assert!(rb.description.is_some());
2093 assert!(rb.description.as_ref().unwrap().contains("file"));
2094 assert!(rb.required);
2095
2096 assert_eq!(
2098 rb.schema,
2099 RequestBodySchema::MultipartFile {
2100 field_name: "file".to_owned()
2101 }
2102 );
2103
2104 assert!(builder.spec.allowed_request_content_types.is_some());
2106 let allowed = builder.spec.allowed_request_content_types.as_ref().unwrap();
2107 assert_eq!(allowed.len(), 1);
2108 assert!(allowed.contains(&"multipart/form-data"));
2109 }
2110
2111 #[test]
2112 fn multipart_file_request_without_description() {
2113 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/upload")
2114 .multipart_file_request("file", None)
2115 .public()
2116 .handler(test_handler)
2117 .json_response(http::StatusCode::OK, "Success");
2118
2119 assert!(builder.spec.request_body.is_some());
2120 let rb = builder.spec.request_body.as_ref().unwrap();
2121 assert_eq!(rb.content_type, "multipart/form-data");
2122 assert!(rb.description.is_none());
2123 assert_eq!(
2124 rb.schema,
2125 RequestBodySchema::MultipartFile {
2126 field_name: "file".to_owned()
2127 }
2128 );
2129 }
2130
2131 #[test]
2132 fn octet_stream_request() {
2133 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/upload")
2134 .operation_id("test.upload")
2135 .summary("Upload raw file")
2136 .octet_stream_request(Some("Raw file bytes"))
2137 .public()
2138 .handler(test_handler)
2139 .json_response(http::StatusCode::OK, "Success");
2140
2141 assert!(builder.spec.request_body.is_some());
2143 let rb = builder.spec.request_body.as_ref().unwrap();
2144 assert_eq!(rb.content_type, "application/octet-stream");
2145 assert_eq!(rb.description, Some("Raw file bytes".to_owned()));
2146 assert!(rb.required);
2147
2148 assert_eq!(rb.schema, RequestBodySchema::Binary);
2150
2151 assert!(builder.spec.allowed_request_content_types.is_some());
2153 let allowed = builder.spec.allowed_request_content_types.as_ref().unwrap();
2154 assert_eq!(allowed.len(), 1);
2155 assert!(allowed.contains(&"application/octet-stream"));
2156 }
2157
2158 #[test]
2159 fn octet_stream_request_without_description() {
2160 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/upload")
2161 .octet_stream_request(None)
2162 .public()
2163 .handler(test_handler)
2164 .json_response(http::StatusCode::OK, "Success");
2165
2166 assert!(builder.spec.request_body.is_some());
2167 let rb = builder.spec.request_body.as_ref().unwrap();
2168 assert_eq!(rb.content_type, "application/octet-stream");
2169 assert!(rb.description.is_none());
2170 assert_eq!(rb.schema, RequestBodySchema::Binary);
2171 }
2172
2173 #[test]
2174 fn json_request_uses_ref_schema() {
2175 let registry = MockRegistry::new();
2176 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
2177 .json_request::<SampleDtoRequest>(®istry, "Test request body")
2178 .public()
2179 .handler(test_handler)
2180 .json_response(http::StatusCode::OK, "Success");
2181
2182 assert!(builder.spec.request_body.is_some());
2183 let rb = builder.spec.request_body.as_ref().unwrap();
2184 assert_eq!(rb.content_type, "application/json");
2185
2186 match &rb.schema {
2188 RequestBodySchema::Ref { schema_name } => {
2189 assert!(!schema_name.is_empty());
2190 }
2191 _ => panic!("Expected RequestBodySchema::Ref for JSON request"),
2192 }
2193 }
2194
2195 #[test]
2196 fn response_content_types_must_not_contain_parameters() {
2197 let registry = MockRegistry::new();
2200 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
2201 .operation_id("test.content_type_purity")
2202 .summary("Test response content types")
2203 .json_request::<SampleDtoRequest>(®istry, "Test")
2204 .public()
2205 .handler(test_handler)
2206 .text_response(http::StatusCode::OK, "Text", "text/plain")
2207 .text_response(http::StatusCode::OK, "Markdown", "text/markdown")
2208 .html_response(http::StatusCode::OK, "HTML")
2209 .json_response(http::StatusCode::OK, "JSON")
2210 .problem_response(®istry, http::StatusCode::BAD_REQUEST, "Error");
2211
2212 for response in &builder.spec.responses {
2214 assert!(
2215 !response.content_type.contains(';'),
2216 "Response content_type '{}' must not contain parameters. \
2217 Use pure media type without charset or other parameters. \
2218 OpenAPI media type keys cannot include parameters.",
2219 response.content_type
2220 );
2221 }
2222 }
2223}