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 pub fn no_license_required(self) -> OperationBuilder<H, R, S, AuthSet, LicenseSet> {
857 OperationBuilder {
858 spec: self.spec,
859 method_router: self.method_router,
860 _has_handler: self._has_handler,
861 _has_response: self._has_response,
862 _state: self._state,
863 _auth_state: self._auth_state,
864 _license_state: PhantomData,
865 }
866 }
867}
868
869impl<H, R, S, L> OperationBuilder<H, R, S, AuthNotSet, L>
873where
874 H: HandlerSlot<S>,
875 L: LicenseState,
876{
877 pub fn authenticated(mut self) -> OperationBuilder<H, R, S, AuthSet, L> {
927 self.spec.authenticated = true;
928 self.spec.is_public = false;
929 OperationBuilder {
930 spec: self.spec,
931 method_router: self.method_router,
932 _has_handler: self._has_handler,
933 _has_response: self._has_response,
934 _state: self._state,
935 _auth_state: PhantomData,
936 _license_state: self._license_state,
937 }
938 }
939
940 pub fn public(mut self) -> OperationBuilder<H, R, S, AuthSet, LicenseSet> {
964 self.spec.is_public = true;
965 self.spec.authenticated = false;
966 OperationBuilder {
967 spec: self.spec,
968 method_router: self.method_router,
969 _has_handler: self._has_handler,
970 _has_response: self._has_response,
971 _state: self._state,
972 _auth_state: PhantomData,
973 _license_state: PhantomData,
974 }
975 }
976}
977
978impl<R, S, A, L> OperationBuilder<Missing, R, S, A, L>
982where
983 S: Clone + Send + Sync + 'static,
984 A: AuthState,
985 L: LicenseState,
986{
987 pub fn handler<F, T>(self, h: F) -> OperationBuilder<Present, R, S, A, L>
991 where
992 F: Handler<T, S> + Clone + Send + 'static,
993 T: 'static,
994 {
995 let method_router = match self.spec.method {
996 Method::GET => axum::routing::get(h),
997 Method::POST => axum::routing::post(h),
998 Method::PUT => axum::routing::put(h),
999 Method::DELETE => axum::routing::delete(h),
1000 Method::PATCH => axum::routing::patch(h),
1001 _ => axum::routing::any(|| async { axum::http::StatusCode::METHOD_NOT_ALLOWED }),
1002 };
1003
1004 OperationBuilder {
1005 spec: self.spec,
1006 method_router, _has_handler: PhantomData::<Present>,
1008 _has_response: self._has_response,
1009 _state: self._state,
1010 _auth_state: self._auth_state,
1011 _license_state: self._license_state,
1012 }
1013 }
1014
1015 pub fn method_router(self, mr: MethodRouter<S>) -> OperationBuilder<Present, R, S, A, L> {
1018 OperationBuilder {
1019 spec: self.spec,
1020 method_router: mr, _has_handler: PhantomData::<Present>,
1022 _has_response: self._has_response,
1023 _state: self._state,
1024 _auth_state: self._auth_state,
1025 _license_state: self._license_state,
1026 }
1027 }
1028}
1029
1030impl<H, S, A, L> OperationBuilder<H, Missing, S, A, L>
1034where
1035 H: HandlerSlot<S>,
1036 A: AuthState,
1037 L: LicenseState,
1038{
1039 pub fn response(mut self, resp: ResponseSpec) -> OperationBuilder<H, Present, S, A, L> {
1041 self.spec.responses.push(resp);
1042 OperationBuilder {
1043 spec: self.spec,
1044 method_router: self.method_router,
1045 _has_handler: self._has_handler,
1046 _has_response: PhantomData::<Present>,
1047 _state: self._state,
1048 _auth_state: self._auth_state,
1049 _license_state: self._license_state,
1050 }
1051 }
1052
1053 pub fn json_response(
1055 mut self,
1056 status: http::StatusCode,
1057 description: impl Into<String>,
1058 ) -> OperationBuilder<H, Present, S, A, L> {
1059 self.spec.responses.push(ResponseSpec {
1060 status: status.as_u16(),
1061 content_type: "application/json",
1062 description: description.into(),
1063 schema_name: None,
1064 });
1065 OperationBuilder {
1066 spec: self.spec,
1067 method_router: self.method_router,
1068 _has_handler: self._has_handler,
1069 _has_response: PhantomData::<Present>,
1070 _state: self._state,
1071 _auth_state: self._auth_state,
1072 _license_state: self._license_state,
1073 }
1074 }
1075
1076 pub fn json_response_with_schema<T>(
1078 mut self,
1079 registry: &dyn OpenApiRegistry,
1080 status: http::StatusCode,
1081 description: impl Into<String>,
1082 ) -> OperationBuilder<H, Present, S, A, L>
1083 where
1084 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
1085 {
1086 let name = ensure_schema::<T>(registry);
1087 self.spec.responses.push(ResponseSpec {
1088 status: status.as_u16(),
1089 content_type: "application/json",
1090 description: description.into(),
1091 schema_name: Some(name),
1092 });
1093 OperationBuilder {
1094 spec: self.spec,
1095 method_router: self.method_router,
1096 _has_handler: self._has_handler,
1097 _has_response: PhantomData::<Present>,
1098 _state: self._state,
1099 _auth_state: self._auth_state,
1100 _license_state: self._license_state,
1101 }
1102 }
1103
1104 pub fn text_response(
1117 mut self,
1118 status: http::StatusCode,
1119 description: impl Into<String>,
1120 content_type: &'static str,
1121 ) -> OperationBuilder<H, Present, S, A, L> {
1122 self.spec.responses.push(ResponseSpec {
1123 status: status.as_u16(),
1124 content_type,
1125 description: description.into(),
1126 schema_name: None,
1127 });
1128 OperationBuilder {
1129 spec: self.spec,
1130 method_router: self.method_router,
1131 _has_handler: self._has_handler,
1132 _has_response: PhantomData::<Present>,
1133 _state: self._state,
1134 _auth_state: self._auth_state,
1135 _license_state: self._license_state,
1136 }
1137 }
1138
1139 pub fn html_response(
1141 mut self,
1142 status: http::StatusCode,
1143 description: impl Into<String>,
1144 ) -> OperationBuilder<H, Present, S, A, L> {
1145 self.spec.responses.push(ResponseSpec {
1146 status: status.as_u16(),
1147 content_type: "text/html",
1148 description: description.into(),
1149 schema_name: None,
1150 });
1151 OperationBuilder {
1152 spec: self.spec,
1153 method_router: self.method_router,
1154 _has_handler: self._has_handler,
1155 _has_response: PhantomData::<Present>,
1156 _state: self._state,
1157 _auth_state: self._auth_state,
1158 _license_state: self._license_state,
1159 }
1160 }
1161
1162 pub fn problem_response(
1164 mut self,
1165 registry: &dyn OpenApiRegistry,
1166 status: http::StatusCode,
1167 description: impl Into<String>,
1168 ) -> OperationBuilder<H, Present, S, A, L> {
1169 let problem_name = ensure_schema::<crate::api::problem::Problem>(registry);
1171 self.spec.responses.push(ResponseSpec {
1172 status: status.as_u16(),
1173 content_type: problem::APPLICATION_PROBLEM_JSON,
1174 description: description.into(),
1175 schema_name: Some(problem_name),
1176 });
1177 OperationBuilder {
1178 spec: self.spec,
1179 method_router: self.method_router,
1180 _has_handler: self._has_handler,
1181 _has_response: PhantomData::<Present>,
1182 _state: self._state,
1183 _auth_state: self._auth_state,
1184 _license_state: self._license_state,
1185 }
1186 }
1187
1188 pub fn sse_json<T>(
1190 mut self,
1191 openapi: &dyn OpenApiRegistry,
1192 description: impl Into<String>,
1193 ) -> OperationBuilder<H, Present, S, A, L>
1194 where
1195 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
1196 {
1197 let name = ensure_schema::<T>(openapi);
1198 self.spec.responses.push(ResponseSpec {
1199 status: http::StatusCode::OK.as_u16(),
1200 content_type: "text/event-stream",
1201 description: description.into(),
1202 schema_name: Some(name),
1203 });
1204 OperationBuilder {
1205 spec: self.spec,
1206 method_router: self.method_router,
1207 _has_handler: self._has_handler,
1208 _has_response: PhantomData::<Present>,
1209 _state: self._state,
1210 _auth_state: self._auth_state,
1211 _license_state: self._license_state,
1212 }
1213 }
1214}
1215
1216impl<H, S, A, L> OperationBuilder<H, Present, S, A, L>
1220where
1221 H: HandlerSlot<S>,
1222 A: AuthState,
1223 L: LicenseState,
1224{
1225 pub fn json_response(
1227 mut self,
1228 status: http::StatusCode,
1229 description: impl Into<String>,
1230 ) -> Self {
1231 self.spec.responses.push(ResponseSpec {
1232 status: status.as_u16(),
1233 content_type: "application/json",
1234 description: description.into(),
1235 schema_name: None,
1236 });
1237 self
1238 }
1239
1240 pub fn json_response_with_schema<T>(
1242 mut self,
1243 registry: &dyn OpenApiRegistry,
1244 status: http::StatusCode,
1245 description: impl Into<String>,
1246 ) -> Self
1247 where
1248 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
1249 {
1250 let name = ensure_schema::<T>(registry);
1251 self.spec.responses.push(ResponseSpec {
1252 status: status.as_u16(),
1253 content_type: "application/json",
1254 description: description.into(),
1255 schema_name: Some(name),
1256 });
1257 self
1258 }
1259
1260 pub fn text_response(
1273 mut self,
1274 status: http::StatusCode,
1275 description: impl Into<String>,
1276 content_type: &'static str,
1277 ) -> Self {
1278 self.spec.responses.push(ResponseSpec {
1279 status: status.as_u16(),
1280 content_type,
1281 description: description.into(),
1282 schema_name: None,
1283 });
1284 self
1285 }
1286
1287 pub fn html_response(
1289 mut self,
1290 status: http::StatusCode,
1291 description: impl Into<String>,
1292 ) -> Self {
1293 self.spec.responses.push(ResponseSpec {
1294 status: status.as_u16(),
1295 content_type: "text/html",
1296 description: description.into(),
1297 schema_name: None,
1298 });
1299 self
1300 }
1301
1302 pub fn problem_response(
1304 mut self,
1305 registry: &dyn OpenApiRegistry,
1306 status: http::StatusCode,
1307 description: impl Into<String>,
1308 ) -> Self {
1309 let problem_name = ensure_schema::<crate::api::problem::Problem>(registry);
1310 self.spec.responses.push(ResponseSpec {
1311 status: status.as_u16(),
1312 content_type: problem::APPLICATION_PROBLEM_JSON,
1313 description: description.into(),
1314 schema_name: Some(problem_name),
1315 });
1316 self
1317 }
1318
1319 pub fn sse_json<T>(
1321 mut self,
1322 openapi: &dyn OpenApiRegistry,
1323 description: impl Into<String>,
1324 ) -> Self
1325 where
1326 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
1327 {
1328 let name = ensure_schema::<T>(openapi);
1329 self.spec.responses.push(ResponseSpec {
1330 status: http::StatusCode::OK.as_u16(),
1331 content_type: "text/event-stream",
1332 description: description.into(),
1333 schema_name: Some(name),
1334 });
1335 self
1336 }
1337
1338 pub fn standard_errors(mut self, registry: &dyn OpenApiRegistry) -> Self {
1376 use http::StatusCode;
1377 let problem_name = ensure_schema::<crate::api::problem::Problem>(registry);
1378
1379 let standard_errors = [
1380 (StatusCode::BAD_REQUEST, "Bad Request"),
1381 (StatusCode::UNAUTHORIZED, "Unauthorized"),
1382 (StatusCode::FORBIDDEN, "Forbidden"),
1383 (StatusCode::NOT_FOUND, "Not Found"),
1384 (StatusCode::CONFLICT, "Conflict"),
1385 (StatusCode::UNPROCESSABLE_ENTITY, "Unprocessable Entity"),
1386 (StatusCode::TOO_MANY_REQUESTS, "Too Many Requests"),
1387 (StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error"),
1388 ];
1389
1390 for (status, description) in standard_errors {
1391 self.spec.responses.push(ResponseSpec {
1392 status: status.as_u16(),
1393 content_type: problem::APPLICATION_PROBLEM_JSON,
1394 description: description.to_owned(),
1395 schema_name: Some(problem_name.clone()),
1396 });
1397 }
1398
1399 self
1400 }
1401
1402 pub fn with_422_validation_error(mut self, registry: &dyn OpenApiRegistry) -> Self {
1439 let validation_error_name =
1440 ensure_schema::<crate::api::problem::ValidationErrorResponse>(registry);
1441
1442 self.spec.responses.push(ResponseSpec {
1443 status: http::StatusCode::UNPROCESSABLE_ENTITY.as_u16(),
1444 content_type: problem::APPLICATION_PROBLEM_JSON,
1445 description: "Validation Error".to_owned(),
1446 schema_name: Some(validation_error_name),
1447 });
1448
1449 self
1450 }
1451
1452 pub fn error_400(self, registry: &dyn OpenApiRegistry) -> Self {
1456 self.problem_response(registry, http::StatusCode::BAD_REQUEST, "Bad Request")
1457 }
1458
1459 pub fn error_401(self, registry: &dyn OpenApiRegistry) -> Self {
1463 self.problem_response(registry, http::StatusCode::UNAUTHORIZED, "Unauthorized")
1464 }
1465
1466 pub fn error_403(self, registry: &dyn OpenApiRegistry) -> Self {
1470 self.problem_response(registry, http::StatusCode::FORBIDDEN, "Forbidden")
1471 }
1472
1473 pub fn error_404(self, registry: &dyn OpenApiRegistry) -> Self {
1477 self.problem_response(registry, http::StatusCode::NOT_FOUND, "Not Found")
1478 }
1479
1480 pub fn error_409(self, registry: &dyn OpenApiRegistry) -> Self {
1484 self.problem_response(registry, http::StatusCode::CONFLICT, "Conflict")
1485 }
1486
1487 pub fn error_415(self, registry: &dyn OpenApiRegistry) -> Self {
1491 self.problem_response(
1492 registry,
1493 http::StatusCode::UNSUPPORTED_MEDIA_TYPE,
1494 "Unsupported Media Type",
1495 )
1496 }
1497
1498 pub fn error_422(self, registry: &dyn OpenApiRegistry) -> Self {
1502 self.problem_response(
1503 registry,
1504 http::StatusCode::UNPROCESSABLE_ENTITY,
1505 "Unprocessable Entity",
1506 )
1507 }
1508
1509 pub fn error_429(self, registry: &dyn OpenApiRegistry) -> Self {
1513 self.problem_response(
1514 registry,
1515 http::StatusCode::TOO_MANY_REQUESTS,
1516 "Too Many Requests",
1517 )
1518 }
1519
1520 pub fn error_500(self, registry: &dyn OpenApiRegistry) -> Self {
1524 self.problem_response(
1525 registry,
1526 http::StatusCode::INTERNAL_SERVER_ERROR,
1527 "Internal Server Error",
1528 )
1529 }
1530}
1531
1532impl<S> OperationBuilder<Present, Present, S, AuthSet, LicenseSet>
1536where
1537 S: Clone + Send + Sync + 'static,
1538{
1539 pub fn register(self, router: Router<S>, openapi: &dyn OpenApiRegistry) -> Router<S> {
1548 openapi.register_operation(&self.spec);
1551
1552 router.route(&self.spec.path, self.method_router)
1554 }
1555}
1556
1557#[cfg(test)]
1561#[cfg_attr(coverage_nightly, coverage(off))]
1562mod tests {
1563 use super::*;
1564 use axum::Json;
1565
1566 struct MockRegistry {
1568 operations: std::sync::Mutex<Vec<OperationSpec>>,
1569 schemas: std::sync::Mutex<Vec<String>>,
1570 }
1571
1572 impl MockRegistry {
1573 fn new() -> Self {
1574 Self {
1575 operations: std::sync::Mutex::new(Vec::new()),
1576 schemas: std::sync::Mutex::new(Vec::new()),
1577 }
1578 }
1579 }
1580
1581 enum TestLicenseFeatures {
1582 FeatureA,
1583 FeatureB,
1584 }
1585 impl AsRef<str> for TestLicenseFeatures {
1586 fn as_ref(&self) -> &str {
1587 match self {
1588 TestLicenseFeatures::FeatureA => "feature_a",
1589 TestLicenseFeatures::FeatureB => "feature_b",
1590 }
1591 }
1592 }
1593 impl LicenseFeature for TestLicenseFeatures {}
1594
1595 impl OpenApiRegistry for MockRegistry {
1596 fn register_operation(&self, spec: &OperationSpec) {
1597 if let Ok(mut ops) = self.operations.lock() {
1598 ops.push(spec.clone());
1599 }
1600 }
1601
1602 fn ensure_schema_raw(
1603 &self,
1604 name: &str,
1605 _schemas: Vec<(
1606 String,
1607 utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>,
1608 )>,
1609 ) -> String {
1610 let name = name.to_owned();
1611 if let Ok(mut s) = self.schemas.lock() {
1612 s.push(name.clone());
1613 }
1614 name
1615 }
1616
1617 fn as_any(&self) -> &dyn std::any::Any {
1618 self
1619 }
1620 }
1621
1622 async fn test_handler() -> Json<serde_json::Value> {
1623 Json(serde_json::json!({"status": "ok"}))
1624 }
1625
1626 #[modkit_macros::api_dto(request)]
1627 struct SampleDtoRequest;
1628
1629 #[modkit_macros::api_dto(response)]
1630 struct SampleDtoResponse;
1631
1632 #[test]
1633 fn builder_descriptive_methods() {
1634 let builder = OperationBuilder::<Missing, Missing, (), AuthNotSet>::get("/tests/v1/test")
1635 .operation_id("test.get")
1636 .summary("Test endpoint")
1637 .description("A test endpoint for validation")
1638 .tag("test")
1639 .path_param("id", "Test ID");
1640
1641 assert_eq!(builder.spec.method, Method::GET);
1642 assert_eq!(builder.spec.path, "/tests/v1/test");
1643 assert_eq!(builder.spec.operation_id, Some("test.get".to_owned()));
1644 assert_eq!(builder.spec.summary, Some("Test endpoint".to_owned()));
1645 assert_eq!(
1646 builder.spec.description,
1647 Some("A test endpoint for validation".to_owned())
1648 );
1649 assert_eq!(builder.spec.tags, vec!["test"]);
1650 assert_eq!(builder.spec.params.len(), 1);
1651 }
1652
1653 #[tokio::test]
1654 async fn builder_with_request_response_and_handler() {
1655 let registry = MockRegistry::new();
1656 let router = Router::new();
1657
1658 let _router = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
1659 .summary("Test endpoint")
1660 .json_request::<SampleDtoRequest>(®istry, "optional body") .public()
1662 .handler(test_handler)
1663 .json_response_with_schema::<SampleDtoResponse>(
1664 ®istry,
1665 http::StatusCode::OK,
1666 "Success response",
1667 ) .register(router, ®istry);
1669
1670 let ops = registry.operations.lock().unwrap();
1672 assert_eq!(ops.len(), 1);
1673 let op = &ops[0];
1674 assert_eq!(op.method, Method::POST);
1675 assert_eq!(op.path, "/tests/v1/test");
1676 assert!(op.request_body.is_some());
1677 assert!(op.request_body.as_ref().unwrap().required);
1678 assert_eq!(op.responses.len(), 1);
1679 assert_eq!(op.responses[0].status, 200);
1680
1681 let schemas = registry.schemas.lock().unwrap();
1683 assert!(!schemas.is_empty());
1684 }
1685
1686 #[test]
1687 fn convenience_constructors() {
1688 let get_builder =
1689 OperationBuilder::<Missing, Missing, (), AuthNotSet>::get("/tests/v1/get");
1690 assert_eq!(get_builder.spec.method, Method::GET);
1691 assert_eq!(get_builder.spec.path, "/tests/v1/get");
1692
1693 let post_builder =
1694 OperationBuilder::<Missing, Missing, (), AuthNotSet>::post("/tests/v1/post");
1695 assert_eq!(post_builder.spec.method, Method::POST);
1696 assert_eq!(post_builder.spec.path, "/tests/v1/post");
1697
1698 let put_builder =
1699 OperationBuilder::<Missing, Missing, (), AuthNotSet>::put("/tests/v1/put");
1700 assert_eq!(put_builder.spec.method, Method::PUT);
1701 assert_eq!(put_builder.spec.path, "/tests/v1/put");
1702
1703 let delete_builder =
1704 OperationBuilder::<Missing, Missing, (), AuthNotSet>::delete("/tests/v1/delete");
1705 assert_eq!(delete_builder.spec.method, Method::DELETE);
1706 assert_eq!(delete_builder.spec.path, "/tests/v1/delete");
1707
1708 let patch_builder =
1709 OperationBuilder::<Missing, Missing, (), AuthNotSet>::patch("/tests/v1/patch");
1710 assert_eq!(patch_builder.spec.method, Method::PATCH);
1711 assert_eq!(patch_builder.spec.path, "/tests/v1/patch");
1712 }
1713
1714 #[test]
1715 fn normalize_to_axum_path_should_normalize() {
1716 assert_eq!(
1718 normalize_to_axum_path("/tests/v1/users/{id}"),
1719 "/tests/v1/users/{id}"
1720 );
1721 assert_eq!(
1722 normalize_to_axum_path("/tests/v1/projects/{project_id}/items/{item_id}"),
1723 "/tests/v1/projects/{project_id}/items/{item_id}"
1724 );
1725 assert_eq!(
1726 normalize_to_axum_path("/tests/v1/simple"),
1727 "/tests/v1/simple"
1728 );
1729 assert_eq!(
1730 normalize_to_axum_path("/tests/v1/users/{id}/edit"),
1731 "/tests/v1/users/{id}/edit"
1732 );
1733 }
1734
1735 #[test]
1736 fn axum_to_openapi_path_should_convert() {
1737 assert_eq!(
1739 axum_to_openapi_path("/tests/v1/users/{id}"),
1740 "/tests/v1/users/{id}"
1741 );
1742 assert_eq!(
1743 axum_to_openapi_path("/tests/v1/projects/{project_id}/items/{item_id}"),
1744 "/tests/v1/projects/{project_id}/items/{item_id}"
1745 );
1746 assert_eq!(axum_to_openapi_path("/tests/v1/simple"), "/tests/v1/simple");
1747 assert_eq!(
1749 axum_to_openapi_path("/tests/v1/static/{*path}"),
1750 "/tests/v1/static/{path}"
1751 );
1752 assert_eq!(
1753 axum_to_openapi_path("/tests/v1/files/{*filepath}"),
1754 "/tests/v1/files/{filepath}"
1755 );
1756 }
1757
1758 #[test]
1759 fn path_normalization_in_constructors() {
1760 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/users/{id}");
1762 assert_eq!(builder.spec.path, "/tests/v1/users/{id}");
1763
1764 let builder = OperationBuilder::<Missing, Missing, ()>::post(
1765 "/tests/v1/projects/{project_id}/items/{item_id}",
1766 );
1767 assert_eq!(
1768 builder.spec.path,
1769 "/tests/v1/projects/{project_id}/items/{item_id}"
1770 );
1771
1772 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/simple");
1774 assert_eq!(builder.spec.path, "/tests/v1/simple");
1775 }
1776
1777 #[test]
1778 fn standard_errors() {
1779 let registry = MockRegistry::new();
1780 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1781 .public()
1782 .handler(test_handler)
1783 .json_response(http::StatusCode::OK, "Success")
1784 .standard_errors(®istry);
1785
1786 assert_eq!(builder.spec.responses.len(), 9);
1788
1789 let statuses: Vec<u16> = builder.spec.responses.iter().map(|r| r.status).collect();
1791 assert!(statuses.contains(&200)); assert!(statuses.contains(&400));
1793 assert!(statuses.contains(&401));
1794 assert!(statuses.contains(&403));
1795 assert!(statuses.contains(&404));
1796 assert!(statuses.contains(&409));
1797 assert!(statuses.contains(&422));
1798 assert!(statuses.contains(&429));
1799 assert!(statuses.contains(&500));
1800
1801 let error_responses: Vec<_> = builder
1803 .spec
1804 .responses
1805 .iter()
1806 .filter(|r| r.status >= 400)
1807 .collect();
1808
1809 for resp in error_responses {
1810 assert_eq!(
1811 resp.content_type,
1812 crate::api::problem::APPLICATION_PROBLEM_JSON
1813 );
1814 assert!(resp.schema_name.is_some());
1815 }
1816 }
1817
1818 #[test]
1819 fn authenticated() {
1820 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1821 .authenticated()
1822 .handler(test_handler)
1823 .json_response(http::StatusCode::OK, "Success");
1824
1825 assert!(builder.spec.authenticated);
1826 assert!(!builder.spec.is_public);
1827 }
1828
1829 #[test]
1830 fn require_license_features_none() {
1831 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1832 .authenticated()
1833 .require_license_features::<TestLicenseFeatures>([])
1834 .handler(|| async {})
1835 .json_response(http::StatusCode::OK, "OK");
1836
1837 assert!(builder.spec.license_requirement.is_none());
1838 }
1839
1840 #[test]
1841 fn no_license_required_transitions_and_allows_register() {
1842 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1843 .authenticated()
1844 .no_license_required()
1845 .handler(|| async {})
1846 .json_response(http::StatusCode::OK, "OK");
1847
1848 assert!(builder.spec.license_requirement.is_none());
1849 assert!(!builder.spec.is_public);
1850 }
1851
1852 #[test]
1853 fn require_license_features_one() {
1854 let feature = TestLicenseFeatures::FeatureA;
1855
1856 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1857 .authenticated()
1858 .require_license_features([&feature])
1859 .handler(|| async {})
1860 .json_response(http::StatusCode::OK, "OK");
1861
1862 let license_req = builder
1863 .spec
1864 .license_requirement
1865 .as_ref()
1866 .expect("Should have license requirement");
1867 assert_eq!(license_req.license_names, vec!["feature_a".to_owned()]);
1868 }
1869
1870 #[test]
1871 fn require_license_features_many() {
1872 let feature_a = TestLicenseFeatures::FeatureA;
1873 let feature_b = TestLicenseFeatures::FeatureB;
1874
1875 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1876 .authenticated()
1877 .require_license_features([&feature_a, &feature_b])
1878 .handler(|| async {})
1879 .json_response(http::StatusCode::OK, "OK");
1880
1881 let license_req = builder
1882 .spec
1883 .license_requirement
1884 .as_ref()
1885 .expect("Should have license requirement");
1886 assert_eq!(
1887 license_req.license_names,
1888 vec!["feature_a".to_owned(), "feature_b".to_owned()]
1889 );
1890 }
1891
1892 #[tokio::test]
1893 async fn public_does_not_require_license_features_and_can_register() {
1894 let registry = MockRegistry::new();
1895 let router = Router::new();
1896
1897 let _router = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1898 .public()
1899 .handler(test_handler)
1900 .json_response(http::StatusCode::OK, "Success")
1901 .register(router, ®istry);
1902
1903 let ops = registry.operations.lock().unwrap();
1904 assert_eq!(ops.len(), 1);
1905 assert!(ops[0].license_requirement.is_none());
1906 }
1907
1908 #[test]
1909 fn with_422_validation_error() {
1910 let registry = MockRegistry::new();
1911 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
1912 .public()
1913 .handler(test_handler)
1914 .json_response(http::StatusCode::CREATED, "Created")
1915 .with_422_validation_error(®istry);
1916
1917 assert_eq!(builder.spec.responses.len(), 2);
1919
1920 let validation_response = builder
1921 .spec
1922 .responses
1923 .iter()
1924 .find(|r| r.status == 422)
1925 .expect("Should have 422 response");
1926
1927 assert_eq!(validation_response.description, "Validation Error");
1928 assert_eq!(
1929 validation_response.content_type,
1930 crate::api::problem::APPLICATION_PROBLEM_JSON
1931 );
1932 assert!(validation_response.schema_name.is_some());
1933 }
1934
1935 #[test]
1936 fn allow_content_types_with_existing_request_body() {
1937 let registry = MockRegistry::new();
1938 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
1939 .json_request::<SampleDtoRequest>(®istry, "Test request")
1940 .allow_content_types(&["application/json", "application/xml"])
1941 .public()
1942 .handler(test_handler)
1943 .json_response(http::StatusCode::OK, "Success");
1944
1945 assert!(builder.spec.request_body.is_some());
1947 assert!(builder.spec.allowed_request_content_types.is_some());
1948 let allowed = builder.spec.allowed_request_content_types.as_ref().unwrap();
1949 assert_eq!(allowed.len(), 2);
1950 assert!(allowed.contains(&"application/json"));
1951 assert!(allowed.contains(&"application/xml"));
1952 }
1953
1954 #[test]
1955 fn allow_content_types_without_existing_request_body() {
1956 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
1957 .allow_content_types(&["multipart/form-data"])
1958 .public()
1959 .handler(test_handler)
1960 .json_response(http::StatusCode::OK, "Success");
1961
1962 assert!(builder.spec.request_body.is_none());
1964 assert!(builder.spec.allowed_request_content_types.is_some());
1965 let allowed = builder.spec.allowed_request_content_types.as_ref().unwrap();
1966 assert_eq!(allowed.len(), 1);
1967 assert!(allowed.contains(&"multipart/form-data"));
1968 }
1969
1970 #[test]
1971 fn allow_content_types_can_be_chained() {
1972 let registry = MockRegistry::new();
1973 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
1974 .operation_id("test.post")
1975 .summary("Test endpoint")
1976 .json_request::<SampleDtoRequest>(®istry, "Test request")
1977 .allow_content_types(&["application/json"])
1978 .public()
1979 .handler(test_handler)
1980 .json_response(http::StatusCode::OK, "Success")
1981 .problem_response(
1982 ®istry,
1983 http::StatusCode::UNSUPPORTED_MEDIA_TYPE,
1984 "Unsupported Media Type",
1985 );
1986
1987 assert_eq!(builder.spec.operation_id, Some("test.post".to_owned()));
1988 assert!(builder.spec.request_body.is_some());
1989 assert!(builder.spec.allowed_request_content_types.is_some());
1990 assert_eq!(builder.spec.responses.len(), 2);
1991 }
1992
1993 #[test]
1994 fn multipart_file_request() {
1995 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/upload")
1996 .operation_id("test.upload")
1997 .summary("Upload file")
1998 .multipart_file_request("file", Some("Upload a file"))
1999 .public()
2000 .handler(test_handler)
2001 .json_response(http::StatusCode::OK, "Success");
2002
2003 assert!(builder.spec.request_body.is_some());
2005 let rb = builder.spec.request_body.as_ref().unwrap();
2006 assert_eq!(rb.content_type, "multipart/form-data");
2007 assert!(rb.description.is_some());
2008 assert!(rb.description.as_ref().unwrap().contains("file"));
2009 assert!(rb.required);
2010
2011 assert_eq!(
2013 rb.schema,
2014 RequestBodySchema::MultipartFile {
2015 field_name: "file".to_owned()
2016 }
2017 );
2018
2019 assert!(builder.spec.allowed_request_content_types.is_some());
2021 let allowed = builder.spec.allowed_request_content_types.as_ref().unwrap();
2022 assert_eq!(allowed.len(), 1);
2023 assert!(allowed.contains(&"multipart/form-data"));
2024 }
2025
2026 #[test]
2027 fn multipart_file_request_without_description() {
2028 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/upload")
2029 .multipart_file_request("file", None)
2030 .public()
2031 .handler(test_handler)
2032 .json_response(http::StatusCode::OK, "Success");
2033
2034 assert!(builder.spec.request_body.is_some());
2035 let rb = builder.spec.request_body.as_ref().unwrap();
2036 assert_eq!(rb.content_type, "multipart/form-data");
2037 assert!(rb.description.is_none());
2038 assert_eq!(
2039 rb.schema,
2040 RequestBodySchema::MultipartFile {
2041 field_name: "file".to_owned()
2042 }
2043 );
2044 }
2045
2046 #[test]
2047 fn octet_stream_request() {
2048 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/upload")
2049 .operation_id("test.upload")
2050 .summary("Upload raw file")
2051 .octet_stream_request(Some("Raw file bytes"))
2052 .public()
2053 .handler(test_handler)
2054 .json_response(http::StatusCode::OK, "Success");
2055
2056 assert!(builder.spec.request_body.is_some());
2058 let rb = builder.spec.request_body.as_ref().unwrap();
2059 assert_eq!(rb.content_type, "application/octet-stream");
2060 assert_eq!(rb.description, Some("Raw file bytes".to_owned()));
2061 assert!(rb.required);
2062
2063 assert_eq!(rb.schema, RequestBodySchema::Binary);
2065
2066 assert!(builder.spec.allowed_request_content_types.is_some());
2068 let allowed = builder.spec.allowed_request_content_types.as_ref().unwrap();
2069 assert_eq!(allowed.len(), 1);
2070 assert!(allowed.contains(&"application/octet-stream"));
2071 }
2072
2073 #[test]
2074 fn octet_stream_request_without_description() {
2075 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/upload")
2076 .octet_stream_request(None)
2077 .public()
2078 .handler(test_handler)
2079 .json_response(http::StatusCode::OK, "Success");
2080
2081 assert!(builder.spec.request_body.is_some());
2082 let rb = builder.spec.request_body.as_ref().unwrap();
2083 assert_eq!(rb.content_type, "application/octet-stream");
2084 assert!(rb.description.is_none());
2085 assert_eq!(rb.schema, RequestBodySchema::Binary);
2086 }
2087
2088 #[test]
2089 fn json_request_uses_ref_schema() {
2090 let registry = MockRegistry::new();
2091 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
2092 .json_request::<SampleDtoRequest>(®istry, "Test request body")
2093 .public()
2094 .handler(test_handler)
2095 .json_response(http::StatusCode::OK, "Success");
2096
2097 assert!(builder.spec.request_body.is_some());
2098 let rb = builder.spec.request_body.as_ref().unwrap();
2099 assert_eq!(rb.content_type, "application/json");
2100
2101 match &rb.schema {
2103 RequestBodySchema::Ref { schema_name } => {
2104 assert!(!schema_name.is_empty());
2105 }
2106 _ => panic!("Expected RequestBodySchema::Ref for JSON request"),
2107 }
2108 }
2109
2110 #[test]
2111 fn response_content_types_must_not_contain_parameters() {
2112 let registry = MockRegistry::new();
2115 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
2116 .operation_id("test.content_type_purity")
2117 .summary("Test response content types")
2118 .json_request::<SampleDtoRequest>(®istry, "Test")
2119 .public()
2120 .handler(test_handler)
2121 .text_response(http::StatusCode::OK, "Text", "text/plain")
2122 .text_response(http::StatusCode::OK, "Markdown", "text/markdown")
2123 .html_response(http::StatusCode::OK, "HTML")
2124 .json_response(http::StatusCode::OK, "JSON")
2125 .problem_response(®istry, http::StatusCode::BAD_REQUEST, "Error");
2126
2127 for response in &builder.spec.responses {
2129 assert!(
2130 !response.content_type.contains(';'),
2131 "Response content_type '{}' must not contain parameters. \
2132 Use pure media type without charset or other parameters. \
2133 OpenAPI media type keys cannot include parameters.",
2134 response.content_type
2135 );
2136 }
2137 }
2138}