1use crate::api::{api_dto, problem};
15use axum::{Router, handler::Handler, routing::MethodRouter};
16use http::Method;
17use serde::{Deserialize, Serialize};
18use std::collections::BTreeMap;
19use std::marker::PhantomData;
20
21#[must_use]
36pub fn normalize_to_axum_path(path: &str) -> String {
37 path.to_owned()
42}
43
44#[must_use]
56pub fn axum_to_openapi_path(path: &str) -> String {
57 path.replace("{*", "{")
60}
61
62pub mod state {
64 #[derive(Debug, Clone, Copy)]
66 pub struct Missing;
67
68 #[derive(Debug, Clone, Copy)]
70 pub struct Present;
71
72 #[derive(Debug, Clone, Copy)]
74 pub struct AuthNotSet;
75
76 #[derive(Debug, Clone, Copy)]
78 pub struct AuthSet;
79
80 #[derive(Debug, Clone, Copy)]
82 pub struct LicenseNotSet;
83
84 #[derive(Debug, Clone, Copy)]
86 pub struct LicenseSet;
87}
88
89mod sealed {
93 pub trait Sealed {}
94 pub trait SealedAuth {}
95 pub trait SealedLicenseReq {}
96}
97
98pub trait HandlerSlot<S>: sealed::Sealed {
99 type Slot;
100}
101
102pub trait AuthState: sealed::SealedAuth {}
104
105impl sealed::Sealed for Missing {}
106impl sealed::Sealed for Present {}
107
108impl sealed::SealedAuth for state::AuthNotSet {}
109impl sealed::SealedAuth for state::AuthSet {}
110
111impl AuthState for state::AuthNotSet {}
112impl AuthState for state::AuthSet {}
113
114pub trait LicenseState: sealed::SealedLicenseReq {}
115
116impl sealed::SealedLicenseReq for state::LicenseNotSet {}
117impl sealed::SealedLicenseReq for state::LicenseSet {}
118
119impl LicenseState for state::LicenseNotSet {}
120impl LicenseState for state::LicenseSet {}
121
122impl<S> HandlerSlot<S> for Missing {
123 type Slot = ();
124}
125impl<S> HandlerSlot<S> for Present {
126 type Slot = MethodRouter<S>;
127}
128
129pub use state::{AuthNotSet, AuthSet, LicenseNotSet, LicenseSet, Missing, Present};
130
131#[derive(Clone, Debug)]
133pub struct ParamSpec {
134 pub name: String,
135 pub location: ParamLocation,
136 pub required: bool,
137 pub description: Option<String>,
138 pub param_type: String, }
140
141pub trait LicenseFeature: AsRef<str> {}
142
143impl<T: LicenseFeature + ?Sized> LicenseFeature for &T {}
144
145#[derive(Clone, Debug, PartialEq, Eq)]
146pub enum ParamLocation {
147 Path,
148 Query,
149 Header,
150 Cookie,
151}
152
153#[derive(Clone, Debug, PartialEq, Eq)]
155pub enum RequestBodySchema {
156 Ref { schema_name: String },
158 MultipartFile { field_name: String },
160 Binary,
163 InlineObject,
165}
166
167#[derive(Clone, Debug)]
169pub struct RequestBodySpec {
170 pub content_type: &'static str,
171 pub description: Option<String>,
172 pub schema: RequestBodySchema,
174 pub required: bool,
176}
177
178#[derive(Clone, Debug)]
180pub struct ResponseSpec {
181 pub status: u16,
182 pub content_type: &'static str,
183 pub description: String,
184 pub schema_name: Option<String>,
186}
187
188#[derive(Clone, Debug)]
190pub struct LicenseReqSpec {
191 pub license_names: Vec<String>,
192}
193
194#[derive(Clone, Debug)]
196pub struct OperationSpec {
197 pub method: Method,
198 pub path: String,
199 pub operation_id: Option<String>,
200 pub summary: Option<String>,
201 pub description: Option<String>,
202 pub tags: Vec<String>,
203 pub params: Vec<ParamSpec>,
204 pub request_body: Option<RequestBodySpec>,
205 pub responses: Vec<ResponseSpec>,
206 pub handler_id: String,
208 pub authenticated: bool,
211 pub is_public: bool,
213 pub rate_limit: Option<RateLimitSpec>,
215 pub allowed_request_content_types: Option<Vec<&'static str>>,
221 pub vendor_extensions: VendorExtensions,
223 pub license_requirement: Option<LicenseReqSpec>,
224}
225
226#[derive(Clone, Debug, Default, Deserialize, Serialize)]
227pub struct VendorExtensions {
228 #[serde(rename = "x-odata-filter", skip_serializing_if = "Option::is_none")]
229 pub x_odata_filter: Option<ODataPagination<BTreeMap<String, Vec<String>>>>,
230 #[serde(rename = "x-odata-orderby", skip_serializing_if = "Option::is_none")]
231 pub x_odata_orderby: Option<ODataPagination<Vec<String>>>,
232}
233
234#[derive(Clone, Debug, Default, Deserialize, Serialize)]
235pub struct ODataPagination<T> {
236 #[serde(rename = "allowedFields")]
237 pub allowed_fields: T,
238}
239
240#[derive(Clone, Debug, Default)]
242pub struct RateLimitSpec {
243 pub rps: u32,
245 pub burst: u32,
247 pub in_flight: u32,
249}
250
251#[derive(Clone, Debug, Deserialize, Serialize, Default)]
252#[serde(rename_all = "camelCase")]
253pub struct XPagination {
254 pub filter_fields: BTreeMap<String, Vec<String>>,
255 pub order_by: Vec<String>,
256}
257
258pub trait OperationBuilderODataExt<S, H, R> {
260 #[must_use]
262 fn with_odata_filter<T>(self) -> Self
263 where
264 T: modkit_odata::filter::FilterField;
265
266 #[must_use]
268 fn with_odata_select(self) -> Self;
269
270 #[must_use]
272 fn with_odata_orderby<T>(self) -> Self
273 where
274 T: modkit_odata::filter::FilterField;
275}
276
277impl<S, H, R, A, L> OperationBuilderODataExt<S, H, R> for OperationBuilder<H, R, S, A, L>
278where
279 H: HandlerSlot<S>,
280 A: AuthState,
281 L: LicenseState,
282{
283 fn with_odata_filter<T>(mut self) -> Self
284 where
285 T: modkit_odata::filter::FilterField,
286 {
287 use modkit_odata::filter::FieldKind;
288 use std::fmt::Write as _;
289
290 let mut filter = self
291 .spec
292 .vendor_extensions
293 .x_odata_filter
294 .unwrap_or_default();
295
296 let mut description = "OData v4 filter expression".to_owned();
297 for field in T::FIELDS {
298 let name = field.name().to_owned();
299 let kind = field.kind();
300
301 let ops: Vec<String> = match kind {
302 FieldKind::String => vec!["eq", "ne", "contains", "startswith", "endswith", "in"],
303 FieldKind::Uuid => vec!["eq", "ne", "in"],
304 FieldKind::Bool => vec!["eq", "ne"],
305 FieldKind::I64
306 | FieldKind::F64
307 | FieldKind::Decimal
308 | FieldKind::DateTimeUtc
309 | FieldKind::Date
310 | FieldKind::Time => {
311 vec!["eq", "ne", "gt", "ge", "lt", "le", "in"]
312 }
313 }
314 .into_iter()
315 .map(String::from)
316 .collect();
317
318 _ = write!(description, "\n- {}: {}", name, ops.join("|"));
319 filter.allowed_fields.insert(name.clone(), ops);
320 }
321 self.spec.params.push(ParamSpec {
322 name: "$filter".to_owned(),
323 location: ParamLocation::Query,
324 required: false,
325 description: Some(description),
326 param_type: "string".to_owned(),
327 });
328 self.spec.vendor_extensions.x_odata_filter = Some(filter);
329 self
330 }
331
332 fn with_odata_select(mut self) -> Self {
333 self.spec.params.push(ParamSpec {
334 name: "$select".to_owned(),
335 location: ParamLocation::Query,
336 required: false,
337 description: Some("OData v4 select expression".to_owned()),
338 param_type: "string".to_owned(),
339 });
340 self
341 }
342
343 fn with_odata_orderby<T>(mut self) -> Self
344 where
345 T: modkit_odata::filter::FilterField,
346 {
347 use std::fmt::Write as _;
348 let mut order_by = self
349 .spec
350 .vendor_extensions
351 .x_odata_orderby
352 .unwrap_or_default();
353 let mut description = "OData v4 orderby expression".to_owned();
354 for field in T::FIELDS {
355 let name = field.name().to_owned();
356
357 let asc = format!("{name} asc");
359 let desc = format!("{name} desc");
360
361 _ = write!(description, "\n- {asc}\n- {desc}");
362 if !order_by.allowed_fields.contains(&asc) {
363 order_by.allowed_fields.push(asc);
364 }
365 if !order_by.allowed_fields.contains(&desc) {
366 order_by.allowed_fields.push(desc);
367 }
368 }
369 self.spec.params.push(ParamSpec {
370 name: "$orderby".to_owned(),
371 location: ParamLocation::Query,
372 required: false,
373 description: Some(description),
374 param_type: "string".to_owned(),
375 });
376 self.spec.vendor_extensions.x_odata_orderby = Some(order_by);
377 self
378 }
379}
380
381pub use crate::api::openapi_registry::{OpenApiRegistry, ensure_schema};
383
384#[must_use]
393pub struct OperationBuilder<H = Missing, R = Missing, S = (), A = AuthNotSet, L = LicenseNotSet>
394where
395 H: HandlerSlot<S>,
396 A: AuthState,
397 L: LicenseState,
398{
399 spec: OperationSpec,
400 method_router: <H as HandlerSlot<S>>::Slot,
401 _has_handler: PhantomData<H>,
402 _has_response: PhantomData<R>,
403 #[allow(clippy::type_complexity)]
404 _state: PhantomData<fn() -> S>, _auth_state: PhantomData<A>,
406 _license_state: PhantomData<L>,
407}
408
409impl<S> OperationBuilder<Missing, Missing, S, AuthNotSet> {
413 pub fn new(method: Method, path: impl Into<String>) -> Self {
415 let path_str = path.into();
416 let handler_id = format!(
417 "{}:{}",
418 method.as_str().to_lowercase(),
419 path_str.replace(['/', '{', '}'], "_")
420 );
421
422 Self {
423 spec: OperationSpec {
424 method,
425 path: path_str,
426 operation_id: None,
427 summary: None,
428 description: None,
429 tags: Vec::new(),
430 params: Vec::new(),
431 request_body: None,
432 responses: Vec::new(),
433 handler_id,
434 authenticated: false,
435 is_public: false,
436 rate_limit: None,
437 allowed_request_content_types: None,
438 vendor_extensions: VendorExtensions::default(),
439 license_requirement: None,
440 },
441 method_router: (), _has_handler: PhantomData,
443 _has_response: PhantomData,
444 _state: PhantomData,
445 _auth_state: PhantomData,
446 _license_state: PhantomData,
447 }
448 }
449
450 pub fn get(path: impl Into<String>) -> Self {
452 let path_str = path.into();
453 Self::new(Method::GET, normalize_to_axum_path(&path_str))
454 }
455
456 pub fn post(path: impl Into<String>) -> Self {
458 let path_str = path.into();
459 Self::new(Method::POST, normalize_to_axum_path(&path_str))
460 }
461
462 pub fn put(path: impl Into<String>) -> Self {
464 let path_str = path.into();
465 Self::new(Method::PUT, normalize_to_axum_path(&path_str))
466 }
467
468 pub fn delete(path: impl Into<String>) -> Self {
470 let path_str = path.into();
471 Self::new(Method::DELETE, normalize_to_axum_path(&path_str))
472 }
473
474 pub fn patch(path: impl Into<String>) -> Self {
476 let path_str = path.into();
477 Self::new(Method::PATCH, normalize_to_axum_path(&path_str))
478 }
479}
480
481impl<H, R, S, A, L> OperationBuilder<H, R, S, A, L>
485where
486 H: HandlerSlot<S>,
487 A: AuthState,
488 L: LicenseState,
489{
490 pub fn spec(&self) -> &OperationSpec {
492 &self.spec
493 }
494
495 pub fn operation_id(mut self, id: impl Into<String>) -> Self {
497 self.spec.operation_id = Some(id.into());
498 self
499 }
500
501 pub fn require_rate_limit(&mut self, rps: u32, burst: u32, in_flight: u32) -> &mut Self {
504 self.spec.rate_limit = Some(RateLimitSpec {
505 rps,
506 burst,
507 in_flight,
508 });
509 self
510 }
511
512 pub fn summary(mut self, text: impl Into<String>) -> Self {
514 self.spec.summary = Some(text.into());
515 self
516 }
517
518 pub fn description(mut self, text: impl Into<String>) -> Self {
520 self.spec.description = Some(text.into());
521 self
522 }
523
524 pub fn tag(mut self, tag: impl Into<String>) -> Self {
526 self.spec.tags.push(tag.into());
527 self
528 }
529
530 pub fn param(mut self, param: ParamSpec) -> Self {
532 self.spec.params.push(param);
533 self
534 }
535
536 pub fn path_param(mut self, name: impl Into<String>, description: impl Into<String>) -> Self {
538 self.spec.params.push(ParamSpec {
539 name: name.into(),
540 location: ParamLocation::Path,
541 required: true,
542 description: Some(description.into()),
543 param_type: "string".to_owned(),
544 });
545 self
546 }
547
548 pub fn query_param(
550 mut self,
551 name: impl Into<String>,
552 required: bool,
553 description: impl Into<String>,
554 ) -> Self {
555 self.spec.params.push(ParamSpec {
556 name: name.into(),
557 location: ParamLocation::Query,
558 required,
559 description: Some(description.into()),
560 param_type: "string".to_owned(),
561 });
562 self
563 }
564
565 pub fn query_param_typed(
567 mut self,
568 name: impl Into<String>,
569 required: bool,
570 description: impl Into<String>,
571 param_type: impl Into<String>,
572 ) -> Self {
573 self.spec.params.push(ParamSpec {
574 name: name.into(),
575 location: ParamLocation::Query,
576 required,
577 description: Some(description.into()),
578 param_type: param_type.into(),
579 });
580 self
581 }
582
583 pub fn json_request_schema(
586 mut self,
587 schema_name: impl Into<String>,
588 desc: impl Into<String>,
589 ) -> Self {
590 self.spec.request_body = Some(RequestBodySpec {
591 content_type: "application/json",
592 description: Some(desc.into()),
593 schema: RequestBodySchema::Ref {
594 schema_name: schema_name.into(),
595 },
596 required: true,
597 });
598 self
599 }
600
601 pub fn json_request_schema_no_desc(mut self, schema_name: impl Into<String>) -> Self {
604 self.spec.request_body = Some(RequestBodySpec {
605 content_type: "application/json",
606 description: None,
607 schema: RequestBodySchema::Ref {
608 schema_name: schema_name.into(),
609 },
610 required: true,
611 });
612 self
613 }
614
615 pub fn json_request<T>(
618 mut self,
619 registry: &dyn OpenApiRegistry,
620 desc: impl Into<String>,
621 ) -> Self
622 where
623 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::RequestApiDto + 'static,
624 {
625 let name = ensure_schema::<T>(registry);
626 self.spec.request_body = Some(RequestBodySpec {
627 content_type: "application/json",
628 description: Some(desc.into()),
629 schema: RequestBodySchema::Ref { schema_name: name },
630 required: true,
631 });
632 self
633 }
634
635 pub fn json_request_no_desc<T>(mut self, registry: &dyn OpenApiRegistry) -> Self
638 where
639 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::RequestApiDto + 'static,
640 {
641 let name = ensure_schema::<T>(registry);
642 self.spec.request_body = Some(RequestBodySpec {
643 content_type: "application/json",
644 description: None,
645 schema: RequestBodySchema::Ref { schema_name: name },
646 required: true,
647 });
648 self
649 }
650
651 pub fn request_optional(mut self) -> Self {
653 if let Some(rb) = &mut self.spec.request_body {
654 rb.required = false;
655 }
656 self
657 }
658
659 pub fn multipart_file_request(mut self, field_name: &str, description: Option<&str>) -> Self {
697 self.spec.request_body = Some(RequestBodySpec {
699 content_type: "multipart/form-data",
700 description: description
701 .map(|s| format!("{s} (expects field '{field_name}' with file data)")),
702 schema: RequestBodySchema::MultipartFile {
703 field_name: field_name.to_owned(),
704 },
705 required: true,
706 });
707
708 self.spec.allowed_request_content_types = Some(vec!["multipart/form-data"]);
710
711 self
712 }
713
714 pub fn octet_stream_request(mut self, description: Option<&str>) -> Self {
758 self.spec.request_body = Some(RequestBodySpec {
759 content_type: "application/octet-stream",
760 description: description.map(ToString::to_string),
761 schema: RequestBodySchema::Binary,
762 required: true,
763 });
764
765 self.spec.allowed_request_content_types = Some(vec!["application/octet-stream"]);
767
768 self
769 }
770
771 pub fn allow_content_types(mut self, types: &[&'static str]) -> Self {
801 self.spec.allowed_request_content_types = Some(types.to_vec());
802 self
803 }
804}
805
806impl<H, R, S> OperationBuilder<H, R, S, AuthSet, LicenseNotSet>
808where
809 H: HandlerSlot<S>,
810{
811 pub fn require_license_features<F>(
824 mut self,
825 licenses: impl IntoIterator<Item = F>,
826 ) -> OperationBuilder<H, R, S, AuthSet, LicenseSet>
827 where
828 F: LicenseFeature,
829 {
830 let license_names: Vec<String> = licenses
831 .into_iter()
832 .map(|l| l.as_ref().to_owned())
833 .collect();
834
835 self.spec.license_requirement =
836 (!license_names.is_empty()).then_some(LicenseReqSpec { license_names });
837
838 OperationBuilder {
839 spec: self.spec,
840 method_router: self.method_router,
841 _has_handler: self._has_handler,
842 _has_response: self._has_response,
843 _state: self._state,
844 _auth_state: self._auth_state,
845 _license_state: PhantomData,
846 }
847 }
848}
849
850impl<H, R, S, L> OperationBuilder<H, R, S, AuthNotSet, L>
854where
855 H: HandlerSlot<S>,
856 L: LicenseState,
857{
858 pub fn authenticated(mut self) -> OperationBuilder<H, R, S, AuthSet, L> {
908 self.spec.authenticated = true;
909 self.spec.is_public = false;
910 OperationBuilder {
911 spec: self.spec,
912 method_router: self.method_router,
913 _has_handler: self._has_handler,
914 _has_response: self._has_response,
915 _state: self._state,
916 _auth_state: PhantomData,
917 _license_state: self._license_state,
918 }
919 }
920
921 pub fn public(mut self) -> OperationBuilder<H, R, S, AuthSet, LicenseSet> {
945 self.spec.is_public = true;
946 self.spec.authenticated = false;
947 OperationBuilder {
948 spec: self.spec,
949 method_router: self.method_router,
950 _has_handler: self._has_handler,
951 _has_response: self._has_response,
952 _state: self._state,
953 _auth_state: PhantomData,
954 _license_state: PhantomData,
955 }
956 }
957}
958
959impl<R, S, A, L> OperationBuilder<Missing, R, S, A, L>
963where
964 S: Clone + Send + Sync + 'static,
965 A: AuthState,
966 L: LicenseState,
967{
968 pub fn handler<F, T>(self, h: F) -> OperationBuilder<Present, R, S, A, L>
972 where
973 F: Handler<T, S> + Clone + Send + 'static,
974 T: 'static,
975 {
976 let method_router = match self.spec.method {
977 Method::GET => axum::routing::get(h),
978 Method::POST => axum::routing::post(h),
979 Method::PUT => axum::routing::put(h),
980 Method::DELETE => axum::routing::delete(h),
981 Method::PATCH => axum::routing::patch(h),
982 _ => axum::routing::any(|| async { axum::http::StatusCode::METHOD_NOT_ALLOWED }),
983 };
984
985 OperationBuilder {
986 spec: self.spec,
987 method_router, _has_handler: PhantomData::<Present>,
989 _has_response: self._has_response,
990 _state: self._state,
991 _auth_state: self._auth_state,
992 _license_state: self._license_state,
993 }
994 }
995
996 pub fn method_router(self, mr: MethodRouter<S>) -> OperationBuilder<Present, R, S, A, L> {
999 OperationBuilder {
1000 spec: self.spec,
1001 method_router: mr, _has_handler: PhantomData::<Present>,
1003 _has_response: self._has_response,
1004 _state: self._state,
1005 _auth_state: self._auth_state,
1006 _license_state: self._license_state,
1007 }
1008 }
1009}
1010
1011impl<H, S, A, L> OperationBuilder<H, Missing, S, A, L>
1015where
1016 H: HandlerSlot<S>,
1017 A: AuthState,
1018 L: LicenseState,
1019{
1020 pub fn response(mut self, resp: ResponseSpec) -> OperationBuilder<H, Present, S, A, L> {
1022 self.spec.responses.push(resp);
1023 OperationBuilder {
1024 spec: self.spec,
1025 method_router: self.method_router,
1026 _has_handler: self._has_handler,
1027 _has_response: PhantomData::<Present>,
1028 _state: self._state,
1029 _auth_state: self._auth_state,
1030 _license_state: self._license_state,
1031 }
1032 }
1033
1034 pub fn json_response(
1036 mut self,
1037 status: http::StatusCode,
1038 description: impl Into<String>,
1039 ) -> OperationBuilder<H, Present, S, A, L> {
1040 self.spec.responses.push(ResponseSpec {
1041 status: status.as_u16(),
1042 content_type: "application/json",
1043 description: description.into(),
1044 schema_name: None,
1045 });
1046 OperationBuilder {
1047 spec: self.spec,
1048 method_router: self.method_router,
1049 _has_handler: self._has_handler,
1050 _has_response: PhantomData::<Present>,
1051 _state: self._state,
1052 _auth_state: self._auth_state,
1053 _license_state: self._license_state,
1054 }
1055 }
1056
1057 pub fn json_response_with_schema<T>(
1059 mut self,
1060 registry: &dyn OpenApiRegistry,
1061 status: http::StatusCode,
1062 description: impl Into<String>,
1063 ) -> OperationBuilder<H, Present, S, A, L>
1064 where
1065 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
1066 {
1067 let name = ensure_schema::<T>(registry);
1068 self.spec.responses.push(ResponseSpec {
1069 status: status.as_u16(),
1070 content_type: "application/json",
1071 description: description.into(),
1072 schema_name: Some(name),
1073 });
1074 OperationBuilder {
1075 spec: self.spec,
1076 method_router: self.method_router,
1077 _has_handler: self._has_handler,
1078 _has_response: PhantomData::<Present>,
1079 _state: self._state,
1080 _auth_state: self._auth_state,
1081 _license_state: self._license_state,
1082 }
1083 }
1084
1085 pub fn text_response(
1098 mut self,
1099 status: http::StatusCode,
1100 description: impl Into<String>,
1101 content_type: &'static str,
1102 ) -> OperationBuilder<H, Present, S, A, L> {
1103 self.spec.responses.push(ResponseSpec {
1104 status: status.as_u16(),
1105 content_type,
1106 description: description.into(),
1107 schema_name: None,
1108 });
1109 OperationBuilder {
1110 spec: self.spec,
1111 method_router: self.method_router,
1112 _has_handler: self._has_handler,
1113 _has_response: PhantomData::<Present>,
1114 _state: self._state,
1115 _auth_state: self._auth_state,
1116 _license_state: self._license_state,
1117 }
1118 }
1119
1120 pub fn html_response(
1122 mut self,
1123 status: http::StatusCode,
1124 description: impl Into<String>,
1125 ) -> OperationBuilder<H, Present, S, A, L> {
1126 self.spec.responses.push(ResponseSpec {
1127 status: status.as_u16(),
1128 content_type: "text/html",
1129 description: description.into(),
1130 schema_name: None,
1131 });
1132 OperationBuilder {
1133 spec: self.spec,
1134 method_router: self.method_router,
1135 _has_handler: self._has_handler,
1136 _has_response: PhantomData::<Present>,
1137 _state: self._state,
1138 _auth_state: self._auth_state,
1139 _license_state: self._license_state,
1140 }
1141 }
1142
1143 pub fn problem_response(
1145 mut self,
1146 registry: &dyn OpenApiRegistry,
1147 status: http::StatusCode,
1148 description: impl Into<String>,
1149 ) -> OperationBuilder<H, Present, S, A, L> {
1150 let problem_name = ensure_schema::<crate::api::problem::Problem>(registry);
1152 self.spec.responses.push(ResponseSpec {
1153 status: status.as_u16(),
1154 content_type: problem::APPLICATION_PROBLEM_JSON,
1155 description: description.into(),
1156 schema_name: Some(problem_name),
1157 });
1158 OperationBuilder {
1159 spec: self.spec,
1160 method_router: self.method_router,
1161 _has_handler: self._has_handler,
1162 _has_response: PhantomData::<Present>,
1163 _state: self._state,
1164 _auth_state: self._auth_state,
1165 _license_state: self._license_state,
1166 }
1167 }
1168
1169 pub fn sse_json<T>(
1171 mut self,
1172 openapi: &dyn OpenApiRegistry,
1173 description: impl Into<String>,
1174 ) -> OperationBuilder<H, Present, S, A, L>
1175 where
1176 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
1177 {
1178 let name = ensure_schema::<T>(openapi);
1179 self.spec.responses.push(ResponseSpec {
1180 status: http::StatusCode::OK.as_u16(),
1181 content_type: "text/event-stream",
1182 description: description.into(),
1183 schema_name: Some(name),
1184 });
1185 OperationBuilder {
1186 spec: self.spec,
1187 method_router: self.method_router,
1188 _has_handler: self._has_handler,
1189 _has_response: PhantomData::<Present>,
1190 _state: self._state,
1191 _auth_state: self._auth_state,
1192 _license_state: self._license_state,
1193 }
1194 }
1195}
1196
1197impl<H, S, A, L> OperationBuilder<H, Present, S, A, L>
1201where
1202 H: HandlerSlot<S>,
1203 A: AuthState,
1204 L: LicenseState,
1205{
1206 pub fn json_response(
1208 mut self,
1209 status: http::StatusCode,
1210 description: impl Into<String>,
1211 ) -> Self {
1212 self.spec.responses.push(ResponseSpec {
1213 status: status.as_u16(),
1214 content_type: "application/json",
1215 description: description.into(),
1216 schema_name: None,
1217 });
1218 self
1219 }
1220
1221 pub fn json_response_with_schema<T>(
1223 mut self,
1224 registry: &dyn OpenApiRegistry,
1225 status: http::StatusCode,
1226 description: impl Into<String>,
1227 ) -> Self
1228 where
1229 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
1230 {
1231 let name = ensure_schema::<T>(registry);
1232 self.spec.responses.push(ResponseSpec {
1233 status: status.as_u16(),
1234 content_type: "application/json",
1235 description: description.into(),
1236 schema_name: Some(name),
1237 });
1238 self
1239 }
1240
1241 pub fn text_response(
1254 mut self,
1255 status: http::StatusCode,
1256 description: impl Into<String>,
1257 content_type: &'static str,
1258 ) -> Self {
1259 self.spec.responses.push(ResponseSpec {
1260 status: status.as_u16(),
1261 content_type,
1262 description: description.into(),
1263 schema_name: None,
1264 });
1265 self
1266 }
1267
1268 pub fn html_response(
1270 mut self,
1271 status: http::StatusCode,
1272 description: impl Into<String>,
1273 ) -> Self {
1274 self.spec.responses.push(ResponseSpec {
1275 status: status.as_u16(),
1276 content_type: "text/html",
1277 description: description.into(),
1278 schema_name: None,
1279 });
1280 self
1281 }
1282
1283 pub fn problem_response(
1285 mut self,
1286 registry: &dyn OpenApiRegistry,
1287 status: http::StatusCode,
1288 description: impl Into<String>,
1289 ) -> Self {
1290 let problem_name = ensure_schema::<crate::api::problem::Problem>(registry);
1291 self.spec.responses.push(ResponseSpec {
1292 status: status.as_u16(),
1293 content_type: problem::APPLICATION_PROBLEM_JSON,
1294 description: description.into(),
1295 schema_name: Some(problem_name),
1296 });
1297 self
1298 }
1299
1300 pub fn sse_json<T>(
1302 mut self,
1303 openapi: &dyn OpenApiRegistry,
1304 description: impl Into<String>,
1305 ) -> Self
1306 where
1307 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
1308 {
1309 let name = ensure_schema::<T>(openapi);
1310 self.spec.responses.push(ResponseSpec {
1311 status: http::StatusCode::OK.as_u16(),
1312 content_type: "text/event-stream",
1313 description: description.into(),
1314 schema_name: Some(name),
1315 });
1316 self
1317 }
1318
1319 pub fn standard_errors(mut self, registry: &dyn OpenApiRegistry) -> Self {
1357 use http::StatusCode;
1358 let problem_name = ensure_schema::<crate::api::problem::Problem>(registry);
1359
1360 let standard_errors = [
1361 (StatusCode::BAD_REQUEST, "Bad Request"),
1362 (StatusCode::UNAUTHORIZED, "Unauthorized"),
1363 (StatusCode::FORBIDDEN, "Forbidden"),
1364 (StatusCode::NOT_FOUND, "Not Found"),
1365 (StatusCode::CONFLICT, "Conflict"),
1366 (StatusCode::UNPROCESSABLE_ENTITY, "Unprocessable Entity"),
1367 (StatusCode::TOO_MANY_REQUESTS, "Too Many Requests"),
1368 (StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error"),
1369 ];
1370
1371 for (status, description) in standard_errors {
1372 self.spec.responses.push(ResponseSpec {
1373 status: status.as_u16(),
1374 content_type: problem::APPLICATION_PROBLEM_JSON,
1375 description: description.to_owned(),
1376 schema_name: Some(problem_name.clone()),
1377 });
1378 }
1379
1380 self
1381 }
1382
1383 pub fn with_422_validation_error(mut self, registry: &dyn OpenApiRegistry) -> Self {
1420 let validation_error_name =
1421 ensure_schema::<crate::api::problem::ValidationErrorResponse>(registry);
1422
1423 self.spec.responses.push(ResponseSpec {
1424 status: http::StatusCode::UNPROCESSABLE_ENTITY.as_u16(),
1425 content_type: problem::APPLICATION_PROBLEM_JSON,
1426 description: "Validation Error".to_owned(),
1427 schema_name: Some(validation_error_name),
1428 });
1429
1430 self
1431 }
1432
1433 pub fn error_400(self, registry: &dyn OpenApiRegistry) -> Self {
1437 self.problem_response(registry, http::StatusCode::BAD_REQUEST, "Bad Request")
1438 }
1439
1440 pub fn error_401(self, registry: &dyn OpenApiRegistry) -> Self {
1444 self.problem_response(registry, http::StatusCode::UNAUTHORIZED, "Unauthorized")
1445 }
1446
1447 pub fn error_403(self, registry: &dyn OpenApiRegistry) -> Self {
1451 self.problem_response(registry, http::StatusCode::FORBIDDEN, "Forbidden")
1452 }
1453
1454 pub fn error_404(self, registry: &dyn OpenApiRegistry) -> Self {
1458 self.problem_response(registry, http::StatusCode::NOT_FOUND, "Not Found")
1459 }
1460
1461 pub fn error_409(self, registry: &dyn OpenApiRegistry) -> Self {
1465 self.problem_response(registry, http::StatusCode::CONFLICT, "Conflict")
1466 }
1467
1468 pub fn error_415(self, registry: &dyn OpenApiRegistry) -> Self {
1472 self.problem_response(
1473 registry,
1474 http::StatusCode::UNSUPPORTED_MEDIA_TYPE,
1475 "Unsupported Media Type",
1476 )
1477 }
1478
1479 pub fn error_422(self, registry: &dyn OpenApiRegistry) -> Self {
1483 self.problem_response(
1484 registry,
1485 http::StatusCode::UNPROCESSABLE_ENTITY,
1486 "Unprocessable Entity",
1487 )
1488 }
1489
1490 pub fn error_429(self, registry: &dyn OpenApiRegistry) -> Self {
1494 self.problem_response(
1495 registry,
1496 http::StatusCode::TOO_MANY_REQUESTS,
1497 "Too Many Requests",
1498 )
1499 }
1500
1501 pub fn error_500(self, registry: &dyn OpenApiRegistry) -> Self {
1505 self.problem_response(
1506 registry,
1507 http::StatusCode::INTERNAL_SERVER_ERROR,
1508 "Internal Server Error",
1509 )
1510 }
1511}
1512
1513impl<S> OperationBuilder<Present, Present, S, AuthSet, LicenseSet>
1517where
1518 S: Clone + Send + Sync + 'static,
1519{
1520 pub fn register(self, router: Router<S>, openapi: &dyn OpenApiRegistry) -> Router<S> {
1529 openapi.register_operation(&self.spec);
1532
1533 router.route(&self.spec.path, self.method_router)
1535 }
1536}
1537
1538#[cfg(test)]
1542#[cfg_attr(coverage_nightly, coverage(off))]
1543mod tests {
1544 use super::*;
1545 use axum::Json;
1546
1547 struct MockRegistry {
1549 operations: std::sync::Mutex<Vec<OperationSpec>>,
1550 schemas: std::sync::Mutex<Vec<String>>,
1551 }
1552
1553 impl MockRegistry {
1554 fn new() -> Self {
1555 Self {
1556 operations: std::sync::Mutex::new(Vec::new()),
1557 schemas: std::sync::Mutex::new(Vec::new()),
1558 }
1559 }
1560 }
1561
1562 enum TestLicenseFeatures {
1563 FeatureA,
1564 FeatureB,
1565 }
1566 impl AsRef<str> for TestLicenseFeatures {
1567 fn as_ref(&self) -> &str {
1568 match self {
1569 TestLicenseFeatures::FeatureA => "feature_a",
1570 TestLicenseFeatures::FeatureB => "feature_b",
1571 }
1572 }
1573 }
1574 impl LicenseFeature for TestLicenseFeatures {}
1575
1576 impl OpenApiRegistry for MockRegistry {
1577 fn register_operation(&self, spec: &OperationSpec) {
1578 if let Ok(mut ops) = self.operations.lock() {
1579 ops.push(spec.clone());
1580 }
1581 }
1582
1583 fn ensure_schema_raw(
1584 &self,
1585 name: &str,
1586 _schemas: Vec<(
1587 String,
1588 utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>,
1589 )>,
1590 ) -> String {
1591 let name = name.to_owned();
1592 if let Ok(mut s) = self.schemas.lock() {
1593 s.push(name.clone());
1594 }
1595 name
1596 }
1597
1598 fn as_any(&self) -> &dyn std::any::Any {
1599 self
1600 }
1601 }
1602
1603 async fn test_handler() -> Json<serde_json::Value> {
1604 Json(serde_json::json!({"status": "ok"}))
1605 }
1606
1607 #[modkit_macros::api_dto(request)]
1608 struct SampleDtoRequest;
1609
1610 #[modkit_macros::api_dto(response)]
1611 struct SampleDtoResponse;
1612
1613 #[test]
1614 fn builder_descriptive_methods() {
1615 let builder = OperationBuilder::<Missing, Missing, (), AuthNotSet>::get("/tests/v1/test")
1616 .operation_id("test.get")
1617 .summary("Test endpoint")
1618 .description("A test endpoint for validation")
1619 .tag("test")
1620 .path_param("id", "Test ID");
1621
1622 assert_eq!(builder.spec.method, Method::GET);
1623 assert_eq!(builder.spec.path, "/tests/v1/test");
1624 assert_eq!(builder.spec.operation_id, Some("test.get".to_owned()));
1625 assert_eq!(builder.spec.summary, Some("Test endpoint".to_owned()));
1626 assert_eq!(
1627 builder.spec.description,
1628 Some("A test endpoint for validation".to_owned())
1629 );
1630 assert_eq!(builder.spec.tags, vec!["test"]);
1631 assert_eq!(builder.spec.params.len(), 1);
1632 }
1633
1634 #[tokio::test]
1635 async fn builder_with_request_response_and_handler() {
1636 let registry = MockRegistry::new();
1637 let router = Router::new();
1638
1639 let _router = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
1640 .summary("Test endpoint")
1641 .json_request::<SampleDtoRequest>(®istry, "optional body") .public()
1643 .handler(test_handler)
1644 .json_response_with_schema::<SampleDtoResponse>(
1645 ®istry,
1646 http::StatusCode::OK,
1647 "Success response",
1648 ) .register(router, ®istry);
1650
1651 let ops = registry.operations.lock().unwrap();
1653 assert_eq!(ops.len(), 1);
1654 let op = &ops[0];
1655 assert_eq!(op.method, Method::POST);
1656 assert_eq!(op.path, "/tests/v1/test");
1657 assert!(op.request_body.is_some());
1658 assert!(op.request_body.as_ref().unwrap().required);
1659 assert_eq!(op.responses.len(), 1);
1660 assert_eq!(op.responses[0].status, 200);
1661
1662 let schemas = registry.schemas.lock().unwrap();
1664 assert!(!schemas.is_empty());
1665 }
1666
1667 #[test]
1668 fn convenience_constructors() {
1669 let get_builder =
1670 OperationBuilder::<Missing, Missing, (), AuthNotSet>::get("/tests/v1/get");
1671 assert_eq!(get_builder.spec.method, Method::GET);
1672 assert_eq!(get_builder.spec.path, "/tests/v1/get");
1673
1674 let post_builder =
1675 OperationBuilder::<Missing, Missing, (), AuthNotSet>::post("/tests/v1/post");
1676 assert_eq!(post_builder.spec.method, Method::POST);
1677 assert_eq!(post_builder.spec.path, "/tests/v1/post");
1678
1679 let put_builder =
1680 OperationBuilder::<Missing, Missing, (), AuthNotSet>::put("/tests/v1/put");
1681 assert_eq!(put_builder.spec.method, Method::PUT);
1682 assert_eq!(put_builder.spec.path, "/tests/v1/put");
1683
1684 let delete_builder =
1685 OperationBuilder::<Missing, Missing, (), AuthNotSet>::delete("/tests/v1/delete");
1686 assert_eq!(delete_builder.spec.method, Method::DELETE);
1687 assert_eq!(delete_builder.spec.path, "/tests/v1/delete");
1688
1689 let patch_builder =
1690 OperationBuilder::<Missing, Missing, (), AuthNotSet>::patch("/tests/v1/patch");
1691 assert_eq!(patch_builder.spec.method, Method::PATCH);
1692 assert_eq!(patch_builder.spec.path, "/tests/v1/patch");
1693 }
1694
1695 #[test]
1696 fn normalize_to_axum_path_should_normalize() {
1697 assert_eq!(
1699 normalize_to_axum_path("/tests/v1/users/{id}"),
1700 "/tests/v1/users/{id}"
1701 );
1702 assert_eq!(
1703 normalize_to_axum_path("/tests/v1/projects/{project_id}/items/{item_id}"),
1704 "/tests/v1/projects/{project_id}/items/{item_id}"
1705 );
1706 assert_eq!(
1707 normalize_to_axum_path("/tests/v1/simple"),
1708 "/tests/v1/simple"
1709 );
1710 assert_eq!(
1711 normalize_to_axum_path("/tests/v1/users/{id}/edit"),
1712 "/tests/v1/users/{id}/edit"
1713 );
1714 }
1715
1716 #[test]
1717 fn axum_to_openapi_path_should_convert() {
1718 assert_eq!(
1720 axum_to_openapi_path("/tests/v1/users/{id}"),
1721 "/tests/v1/users/{id}"
1722 );
1723 assert_eq!(
1724 axum_to_openapi_path("/tests/v1/projects/{project_id}/items/{item_id}"),
1725 "/tests/v1/projects/{project_id}/items/{item_id}"
1726 );
1727 assert_eq!(axum_to_openapi_path("/tests/v1/simple"), "/tests/v1/simple");
1728 assert_eq!(
1730 axum_to_openapi_path("/tests/v1/static/{*path}"),
1731 "/tests/v1/static/{path}"
1732 );
1733 assert_eq!(
1734 axum_to_openapi_path("/tests/v1/files/{*filepath}"),
1735 "/tests/v1/files/{filepath}"
1736 );
1737 }
1738
1739 #[test]
1740 fn path_normalization_in_constructors() {
1741 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/users/{id}");
1743 assert_eq!(builder.spec.path, "/tests/v1/users/{id}");
1744
1745 let builder = OperationBuilder::<Missing, Missing, ()>::post(
1746 "/tests/v1/projects/{project_id}/items/{item_id}",
1747 );
1748 assert_eq!(
1749 builder.spec.path,
1750 "/tests/v1/projects/{project_id}/items/{item_id}"
1751 );
1752
1753 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/simple");
1755 assert_eq!(builder.spec.path, "/tests/v1/simple");
1756 }
1757
1758 #[test]
1759 fn standard_errors() {
1760 let registry = MockRegistry::new();
1761 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1762 .public()
1763 .handler(test_handler)
1764 .json_response(http::StatusCode::OK, "Success")
1765 .standard_errors(®istry);
1766
1767 assert_eq!(builder.spec.responses.len(), 9);
1769
1770 let statuses: Vec<u16> = builder.spec.responses.iter().map(|r| r.status).collect();
1772 assert!(statuses.contains(&200)); assert!(statuses.contains(&400));
1774 assert!(statuses.contains(&401));
1775 assert!(statuses.contains(&403));
1776 assert!(statuses.contains(&404));
1777 assert!(statuses.contains(&409));
1778 assert!(statuses.contains(&422));
1779 assert!(statuses.contains(&429));
1780 assert!(statuses.contains(&500));
1781
1782 let error_responses: Vec<_> = builder
1784 .spec
1785 .responses
1786 .iter()
1787 .filter(|r| r.status >= 400)
1788 .collect();
1789
1790 for resp in error_responses {
1791 assert_eq!(
1792 resp.content_type,
1793 crate::api::problem::APPLICATION_PROBLEM_JSON
1794 );
1795 assert!(resp.schema_name.is_some());
1796 }
1797 }
1798
1799 #[test]
1800 fn authenticated() {
1801 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1802 .authenticated()
1803 .handler(test_handler)
1804 .json_response(http::StatusCode::OK, "Success");
1805
1806 assert!(builder.spec.authenticated);
1807 assert!(!builder.spec.is_public);
1808 }
1809
1810 #[test]
1811 fn require_license_features_none() {
1812 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1813 .authenticated()
1814 .require_license_features::<TestLicenseFeatures>([])
1815 .handler(|| async {})
1816 .json_response(http::StatusCode::OK, "OK");
1817
1818 assert!(builder.spec.license_requirement.is_none());
1819 }
1820
1821 #[test]
1822 fn require_license_features_one() {
1823 let feature = TestLicenseFeatures::FeatureA;
1824
1825 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1826 .authenticated()
1827 .require_license_features([&feature])
1828 .handler(|| async {})
1829 .json_response(http::StatusCode::OK, "OK");
1830
1831 let license_req = builder
1832 .spec
1833 .license_requirement
1834 .as_ref()
1835 .expect("Should have license requirement");
1836 assert_eq!(license_req.license_names, vec!["feature_a".to_owned()]);
1837 }
1838
1839 #[test]
1840 fn require_license_features_many() {
1841 let feature_a = TestLicenseFeatures::FeatureA;
1842 let feature_b = TestLicenseFeatures::FeatureB;
1843
1844 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1845 .authenticated()
1846 .require_license_features([&feature_a, &feature_b])
1847 .handler(|| async {})
1848 .json_response(http::StatusCode::OK, "OK");
1849
1850 let license_req = builder
1851 .spec
1852 .license_requirement
1853 .as_ref()
1854 .expect("Should have license requirement");
1855 assert_eq!(
1856 license_req.license_names,
1857 vec!["feature_a".to_owned(), "feature_b".to_owned()]
1858 );
1859 }
1860
1861 #[tokio::test]
1862 async fn public_does_not_require_license_features_and_can_register() {
1863 let registry = MockRegistry::new();
1864 let router = Router::new();
1865
1866 let _router = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1867 .public()
1868 .handler(test_handler)
1869 .json_response(http::StatusCode::OK, "Success")
1870 .register(router, ®istry);
1871
1872 let ops = registry.operations.lock().unwrap();
1873 assert_eq!(ops.len(), 1);
1874 assert!(ops[0].license_requirement.is_none());
1875 }
1876
1877 #[test]
1878 fn with_422_validation_error() {
1879 let registry = MockRegistry::new();
1880 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
1881 .public()
1882 .handler(test_handler)
1883 .json_response(http::StatusCode::CREATED, "Created")
1884 .with_422_validation_error(®istry);
1885
1886 assert_eq!(builder.spec.responses.len(), 2);
1888
1889 let validation_response = builder
1890 .spec
1891 .responses
1892 .iter()
1893 .find(|r| r.status == 422)
1894 .expect("Should have 422 response");
1895
1896 assert_eq!(validation_response.description, "Validation Error");
1897 assert_eq!(
1898 validation_response.content_type,
1899 crate::api::problem::APPLICATION_PROBLEM_JSON
1900 );
1901 assert!(validation_response.schema_name.is_some());
1902 }
1903
1904 #[test]
1905 fn allow_content_types_with_existing_request_body() {
1906 let registry = MockRegistry::new();
1907 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
1908 .json_request::<SampleDtoRequest>(®istry, "Test request")
1909 .allow_content_types(&["application/json", "application/xml"])
1910 .public()
1911 .handler(test_handler)
1912 .json_response(http::StatusCode::OK, "Success");
1913
1914 assert!(builder.spec.request_body.is_some());
1916 assert!(builder.spec.allowed_request_content_types.is_some());
1917 let allowed = builder.spec.allowed_request_content_types.as_ref().unwrap();
1918 assert_eq!(allowed.len(), 2);
1919 assert!(allowed.contains(&"application/json"));
1920 assert!(allowed.contains(&"application/xml"));
1921 }
1922
1923 #[test]
1924 fn allow_content_types_without_existing_request_body() {
1925 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
1926 .allow_content_types(&["multipart/form-data"])
1927 .public()
1928 .handler(test_handler)
1929 .json_response(http::StatusCode::OK, "Success");
1930
1931 assert!(builder.spec.request_body.is_none());
1933 assert!(builder.spec.allowed_request_content_types.is_some());
1934 let allowed = builder.spec.allowed_request_content_types.as_ref().unwrap();
1935 assert_eq!(allowed.len(), 1);
1936 assert!(allowed.contains(&"multipart/form-data"));
1937 }
1938
1939 #[test]
1940 fn allow_content_types_can_be_chained() {
1941 let registry = MockRegistry::new();
1942 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
1943 .operation_id("test.post")
1944 .summary("Test endpoint")
1945 .json_request::<SampleDtoRequest>(®istry, "Test request")
1946 .allow_content_types(&["application/json"])
1947 .public()
1948 .handler(test_handler)
1949 .json_response(http::StatusCode::OK, "Success")
1950 .problem_response(
1951 ®istry,
1952 http::StatusCode::UNSUPPORTED_MEDIA_TYPE,
1953 "Unsupported Media Type",
1954 );
1955
1956 assert_eq!(builder.spec.operation_id, Some("test.post".to_owned()));
1957 assert!(builder.spec.request_body.is_some());
1958 assert!(builder.spec.allowed_request_content_types.is_some());
1959 assert_eq!(builder.spec.responses.len(), 2);
1960 }
1961
1962 #[test]
1963 fn multipart_file_request() {
1964 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/upload")
1965 .operation_id("test.upload")
1966 .summary("Upload file")
1967 .multipart_file_request("file", Some("Upload a file"))
1968 .public()
1969 .handler(test_handler)
1970 .json_response(http::StatusCode::OK, "Success");
1971
1972 assert!(builder.spec.request_body.is_some());
1974 let rb = builder.spec.request_body.as_ref().unwrap();
1975 assert_eq!(rb.content_type, "multipart/form-data");
1976 assert!(rb.description.is_some());
1977 assert!(rb.description.as_ref().unwrap().contains("file"));
1978 assert!(rb.required);
1979
1980 assert_eq!(
1982 rb.schema,
1983 RequestBodySchema::MultipartFile {
1984 field_name: "file".to_owned()
1985 }
1986 );
1987
1988 assert!(builder.spec.allowed_request_content_types.is_some());
1990 let allowed = builder.spec.allowed_request_content_types.as_ref().unwrap();
1991 assert_eq!(allowed.len(), 1);
1992 assert!(allowed.contains(&"multipart/form-data"));
1993 }
1994
1995 #[test]
1996 fn multipart_file_request_without_description() {
1997 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/upload")
1998 .multipart_file_request("file", None)
1999 .public()
2000 .handler(test_handler)
2001 .json_response(http::StatusCode::OK, "Success");
2002
2003 assert!(builder.spec.request_body.is_some());
2004 let rb = builder.spec.request_body.as_ref().unwrap();
2005 assert_eq!(rb.content_type, "multipart/form-data");
2006 assert!(rb.description.is_none());
2007 assert_eq!(
2008 rb.schema,
2009 RequestBodySchema::MultipartFile {
2010 field_name: "file".to_owned()
2011 }
2012 );
2013 }
2014
2015 #[test]
2016 fn octet_stream_request() {
2017 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/upload")
2018 .operation_id("test.upload")
2019 .summary("Upload raw file")
2020 .octet_stream_request(Some("Raw file bytes"))
2021 .public()
2022 .handler(test_handler)
2023 .json_response(http::StatusCode::OK, "Success");
2024
2025 assert!(builder.spec.request_body.is_some());
2027 let rb = builder.spec.request_body.as_ref().unwrap();
2028 assert_eq!(rb.content_type, "application/octet-stream");
2029 assert_eq!(rb.description, Some("Raw file bytes".to_owned()));
2030 assert!(rb.required);
2031
2032 assert_eq!(rb.schema, RequestBodySchema::Binary);
2034
2035 assert!(builder.spec.allowed_request_content_types.is_some());
2037 let allowed = builder.spec.allowed_request_content_types.as_ref().unwrap();
2038 assert_eq!(allowed.len(), 1);
2039 assert!(allowed.contains(&"application/octet-stream"));
2040 }
2041
2042 #[test]
2043 fn octet_stream_request_without_description() {
2044 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/upload")
2045 .octet_stream_request(None)
2046 .public()
2047 .handler(test_handler)
2048 .json_response(http::StatusCode::OK, "Success");
2049
2050 assert!(builder.spec.request_body.is_some());
2051 let rb = builder.spec.request_body.as_ref().unwrap();
2052 assert_eq!(rb.content_type, "application/octet-stream");
2053 assert!(rb.description.is_none());
2054 assert_eq!(rb.schema, RequestBodySchema::Binary);
2055 }
2056
2057 #[test]
2058 fn json_request_uses_ref_schema() {
2059 let registry = MockRegistry::new();
2060 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
2061 .json_request::<SampleDtoRequest>(®istry, "Test request body")
2062 .public()
2063 .handler(test_handler)
2064 .json_response(http::StatusCode::OK, "Success");
2065
2066 assert!(builder.spec.request_body.is_some());
2067 let rb = builder.spec.request_body.as_ref().unwrap();
2068 assert_eq!(rb.content_type, "application/json");
2069
2070 match &rb.schema {
2072 RequestBodySchema::Ref { schema_name } => {
2073 assert!(!schema_name.is_empty());
2074 }
2075 _ => panic!("Expected RequestBodySchema::Ref for JSON request"),
2076 }
2077 }
2078
2079 #[test]
2080 fn response_content_types_must_not_contain_parameters() {
2081 let registry = MockRegistry::new();
2084 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
2085 .operation_id("test.content_type_purity")
2086 .summary("Test response content types")
2087 .json_request::<SampleDtoRequest>(®istry, "Test")
2088 .public()
2089 .handler(test_handler)
2090 .text_response(http::StatusCode::OK, "Text", "text/plain")
2091 .text_response(http::StatusCode::OK, "Markdown", "text/markdown")
2092 .html_response(http::StatusCode::OK, "HTML")
2093 .json_response(http::StatusCode::OK, "JSON")
2094 .problem_response(®istry, http::StatusCode::BAD_REQUEST, "Error");
2095
2096 for response in &builder.spec.responses {
2098 assert!(
2099 !response.content_type.contains(';'),
2100 "Response content_type '{}' must not contain parameters. \
2101 Use pure media type without charset or other parameters. \
2102 OpenAPI media type keys cannot include parameters.",
2103 response.content_type
2104 );
2105 }
2106 }
2107}