1use crate::api::{api_dto, problem};
16use axum::{Router, handler::Handler, routing::MethodRouter};
17use http::Method;
18use serde::{Deserialize, Serialize};
19use std::collections::BTreeMap;
20use std::marker::PhantomData;
21
22#[must_use]
37pub fn normalize_to_axum_path(path: &str) -> String {
38 path.to_owned()
43}
44
45#[must_use]
57pub fn axum_to_openapi_path(path: &str) -> String {
58 path.replace("{*", "{")
61}
62
63pub mod state {
65 #[derive(Debug, Clone, Copy)]
67 pub struct Missing;
68
69 #[derive(Debug, Clone, Copy)]
71 pub struct Present;
72
73 #[derive(Debug, Clone, Copy)]
75 pub struct AuthNotSet;
76
77 #[derive(Debug, Clone, Copy)]
79 pub struct AuthSet;
80
81 #[derive(Debug, Clone, Copy)]
83 pub struct LicenseNotSet;
84
85 #[derive(Debug, Clone, Copy)]
87 pub struct LicenseSet;
88}
89
90mod sealed {
94 pub trait Sealed {}
95 pub trait SealedAuth {}
96 pub trait SealedLicenseReq {}
97}
98
99pub trait HandlerSlot<S>: sealed::Sealed {
100 type Slot;
101}
102
103pub trait AuthState: sealed::SealedAuth {}
105
106impl sealed::Sealed for Missing {}
107impl sealed::Sealed for Present {}
108
109impl sealed::SealedAuth for state::AuthNotSet {}
110impl sealed::SealedAuth for state::AuthSet {}
111
112impl AuthState for state::AuthNotSet {}
113impl AuthState for state::AuthSet {}
114
115pub trait LicenseState: sealed::SealedLicenseReq {}
116
117impl sealed::SealedLicenseReq for state::LicenseNotSet {}
118impl sealed::SealedLicenseReq for state::LicenseSet {}
119
120impl LicenseState for state::LicenseNotSet {}
121impl LicenseState for state::LicenseSet {}
122
123impl<S> HandlerSlot<S> for Missing {
124 type Slot = ();
125}
126impl<S> HandlerSlot<S> for Present {
127 type Slot = MethodRouter<S>;
128}
129
130pub use state::{AuthNotSet, AuthSet, LicenseNotSet, LicenseSet, Missing, Present};
131
132#[derive(Clone, Debug)]
134pub struct ParamSpec {
135 pub name: String,
136 pub location: ParamLocation,
137 pub required: bool,
138 pub description: Option<String>,
139 pub param_type: String, }
141
142pub trait LicenseFeature: AsRef<str> {}
143
144impl<T: LicenseFeature + ?Sized> LicenseFeature for &T {}
145
146#[derive(Clone, Debug, PartialEq, Eq)]
147pub enum ParamLocation {
148 Path,
149 Query,
150 Header,
151 Cookie,
152}
153
154#[derive(Clone, Debug, PartialEq, Eq)]
156pub enum RequestBodySchema {
157 Ref { schema_name: String },
159 MultipartFile { field_name: String },
161 Binary,
164 InlineObject,
166}
167
168#[derive(Clone, Debug)]
170pub struct RequestBodySpec {
171 pub content_type: &'static str,
172 pub description: Option<String>,
173 pub schema: RequestBodySchema,
175 pub required: bool,
177}
178
179#[derive(Clone, Debug)]
181pub struct ResponseSpec {
182 pub status: u16,
183 pub content_type: &'static str,
184 pub description: String,
185 pub schema_name: Option<String>,
187}
188
189#[derive(Clone, Debug)]
191pub struct LicenseReqSpec {
192 pub license_names: Vec<String>,
193}
194
195#[derive(Clone, Debug)]
197pub struct OperationSpec {
198 pub method: Method,
199 pub path: String,
200 pub operation_id: Option<String>,
201 pub summary: Option<String>,
202 pub description: Option<String>,
203 pub tags: Vec<String>,
204 pub params: Vec<ParamSpec>,
205 pub request_body: Option<RequestBodySpec>,
206 pub responses: Vec<ResponseSpec>,
207 pub handler_id: String,
209 pub authenticated: bool,
212 pub is_public: bool,
214 pub rate_limit: Option<RateLimitSpec>,
216 pub allowed_request_content_types: Option<Vec<&'static str>>,
222 pub vendor_extensions: VendorExtensions,
224 pub license_requirement: Option<LicenseReqSpec>,
225}
226
227#[derive(Clone, Debug, Default, Deserialize, Serialize)]
228pub struct VendorExtensions {
229 #[serde(rename = "x-odata-filter", skip_serializing_if = "Option::is_none")]
230 pub x_odata_filter: Option<ODataPagination<BTreeMap<String, Vec<String>>>>,
231 #[serde(rename = "x-odata-orderby", skip_serializing_if = "Option::is_none")]
232 pub x_odata_orderby: Option<ODataPagination<Vec<String>>>,
233}
234
235#[derive(Clone, Debug, Default, Deserialize, Serialize)]
236pub struct ODataPagination<T> {
237 #[serde(rename = "allowedFields")]
238 pub allowed_fields: T,
239}
240
241#[derive(Clone, Debug, Default)]
243pub struct RateLimitSpec {
244 pub rps: u32,
246 pub burst: u32,
248 pub in_flight: u32,
250}
251
252#[derive(Clone, Debug, Deserialize, Serialize, Default)]
253#[serde(rename_all = "camelCase")]
254pub struct XPagination {
255 pub filter_fields: BTreeMap<String, Vec<String>>,
256 pub order_by: Vec<String>,
257}
258
259pub trait OperationBuilderODataExt<S, H, R> {
261 #[must_use]
263 fn with_odata_filter<T>(self) -> Self
264 where
265 T: modkit_odata::filter::FilterField;
266
267 #[must_use]
269 fn with_odata_select(self) -> Self;
270
271 #[must_use]
273 fn with_odata_orderby<T>(self) -> Self
274 where
275 T: modkit_odata::filter::FilterField;
276}
277
278impl<S, H, R, A, L> OperationBuilderODataExt<S, H, R> for OperationBuilder<H, R, S, A, L>
279where
280 H: HandlerSlot<S>,
281 A: AuthState,
282 L: LicenseState,
283{
284 fn with_odata_filter<T>(mut self) -> Self
285 where
286 T: modkit_odata::filter::FilterField,
287 {
288 use modkit_odata::filter::FieldKind;
289 use std::fmt::Write as _;
290
291 let mut filter = self
292 .spec
293 .vendor_extensions
294 .x_odata_filter
295 .unwrap_or_default();
296
297 let mut description = "OData v4 filter expression".to_owned();
298 for field in T::FIELDS {
299 let name = field.name().to_owned();
300 let kind = field.kind();
301
302 let ops: Vec<String> = match kind {
303 FieldKind::String => vec!["eq", "ne", "contains", "startswith", "endswith", "in"],
304 FieldKind::Uuid => vec!["eq", "ne", "in"],
305 FieldKind::Bool => vec!["eq", "ne"],
306 FieldKind::I64
307 | FieldKind::F64
308 | FieldKind::Decimal
309 | FieldKind::DateTimeUtc
310 | FieldKind::Date
311 | FieldKind::Time => {
312 vec!["eq", "ne", "gt", "ge", "lt", "le", "in"]
313 }
314 }
315 .into_iter()
316 .map(String::from)
317 .collect();
318
319 _ = write!(description, "\n- {}: {}", name, ops.join("|"));
320 filter.allowed_fields.insert(name.clone(), ops);
321 }
322 self.spec.params.push(ParamSpec {
323 name: "$filter".to_owned(),
324 location: ParamLocation::Query,
325 required: false,
326 description: Some(description),
327 param_type: "string".to_owned(),
328 });
329 self.spec.vendor_extensions.x_odata_filter = Some(filter);
330 self
331 }
332
333 fn with_odata_select(mut self) -> Self {
334 self.spec.params.push(ParamSpec {
335 name: "$select".to_owned(),
336 location: ParamLocation::Query,
337 required: false,
338 description: Some("OData v4 select expression".to_owned()),
339 param_type: "string".to_owned(),
340 });
341 self
342 }
343
344 fn with_odata_orderby<T>(mut self) -> Self
345 where
346 T: modkit_odata::filter::FilterField,
347 {
348 use std::fmt::Write as _;
349 let mut order_by = self
350 .spec
351 .vendor_extensions
352 .x_odata_orderby
353 .unwrap_or_default();
354 let mut description = "OData v4 orderby expression".to_owned();
355 for field in T::FIELDS {
356 let name = field.name().to_owned();
357
358 let asc = format!("{name} asc");
360 let desc = format!("{name} desc");
361
362 _ = write!(description, "\n- {asc}\n- {desc}");
363 if !order_by.allowed_fields.contains(&asc) {
364 order_by.allowed_fields.push(asc);
365 }
366 if !order_by.allowed_fields.contains(&desc) {
367 order_by.allowed_fields.push(desc);
368 }
369 }
370 self.spec.params.push(ParamSpec {
371 name: "$orderby".to_owned(),
372 location: ParamLocation::Query,
373 required: false,
374 description: Some(description),
375 param_type: "string".to_owned(),
376 });
377 self.spec.vendor_extensions.x_odata_orderby = Some(order_by);
378 self
379 }
380}
381
382pub use crate::api::openapi_registry::{OpenApiRegistry, ensure_schema};
384
385#[must_use]
394pub struct OperationBuilder<H = Missing, R = Missing, S = (), A = AuthNotSet, L = LicenseNotSet>
395where
396 H: HandlerSlot<S>,
397 A: AuthState,
398 L: LicenseState,
399{
400 spec: OperationSpec,
401 method_router: <H as HandlerSlot<S>>::Slot,
402 _has_handler: PhantomData<H>,
403 _has_response: PhantomData<R>,
404 #[allow(clippy::type_complexity)]
405 _state: PhantomData<fn() -> S>, _auth_state: PhantomData<A>,
407 _license_state: PhantomData<L>,
408}
409
410impl<S> OperationBuilder<Missing, Missing, S, AuthNotSet> {
414 pub fn new(method: Method, path: impl Into<String>) -> Self {
416 let path_str = path.into();
417 let handler_id = format!(
418 "{}:{}",
419 method.as_str().to_lowercase(),
420 path_str.replace(['/', '{', '}'], "_")
421 );
422
423 Self {
424 spec: OperationSpec {
425 method,
426 path: path_str,
427 operation_id: None,
428 summary: None,
429 description: None,
430 tags: Vec::new(),
431 params: Vec::new(),
432 request_body: None,
433 responses: Vec::new(),
434 handler_id,
435 authenticated: false,
436 is_public: false,
437 rate_limit: None,
438 allowed_request_content_types: None,
439 vendor_extensions: VendorExtensions::default(),
440 license_requirement: None,
441 },
442 method_router: (), _has_handler: PhantomData,
444 _has_response: PhantomData,
445 _state: PhantomData,
446 _auth_state: PhantomData,
447 _license_state: PhantomData,
448 }
449 }
450
451 pub fn get(path: impl Into<String>) -> Self {
453 let path_str = path.into();
454 Self::new(Method::GET, normalize_to_axum_path(&path_str))
455 }
456
457 pub fn post(path: impl Into<String>) -> Self {
459 let path_str = path.into();
460 Self::new(Method::POST, normalize_to_axum_path(&path_str))
461 }
462
463 pub fn put(path: impl Into<String>) -> Self {
465 let path_str = path.into();
466 Self::new(Method::PUT, normalize_to_axum_path(&path_str))
467 }
468
469 pub fn delete(path: impl Into<String>) -> Self {
471 let path_str = path.into();
472 Self::new(Method::DELETE, normalize_to_axum_path(&path_str))
473 }
474
475 pub fn patch(path: impl Into<String>) -> Self {
477 let path_str = path.into();
478 Self::new(Method::PATCH, normalize_to_axum_path(&path_str))
479 }
480}
481
482impl<H, R, S, A, L> OperationBuilder<H, R, S, A, L>
486where
487 H: HandlerSlot<S>,
488 A: AuthState,
489 L: LicenseState,
490{
491 pub fn spec(&self) -> &OperationSpec {
493 &self.spec
494 }
495
496 pub fn operation_id(mut self, id: impl Into<String>) -> Self {
498 self.spec.operation_id = Some(id.into());
499 self
500 }
501
502 pub fn require_rate_limit(&mut self, rps: u32, burst: u32, in_flight: u32) -> &mut Self {
505 self.spec.rate_limit = Some(RateLimitSpec {
506 rps,
507 burst,
508 in_flight,
509 });
510 self
511 }
512
513 pub fn summary(mut self, text: impl Into<String>) -> Self {
515 self.spec.summary = Some(text.into());
516 self
517 }
518
519 pub fn description(mut self, text: impl Into<String>) -> Self {
521 self.spec.description = Some(text.into());
522 self
523 }
524
525 pub fn tag(mut self, tag: impl Into<String>) -> Self {
527 self.spec.tags.push(tag.into());
528 self
529 }
530
531 pub fn param(mut self, param: ParamSpec) -> Self {
533 self.spec.params.push(param);
534 self
535 }
536
537 pub fn path_param(mut self, name: impl Into<String>, description: impl Into<String>) -> Self {
539 self.spec.params.push(ParamSpec {
540 name: name.into(),
541 location: ParamLocation::Path,
542 required: true,
543 description: Some(description.into()),
544 param_type: "string".to_owned(),
545 });
546 self
547 }
548
549 pub fn query_param(
551 mut self,
552 name: impl Into<String>,
553 required: bool,
554 description: impl Into<String>,
555 ) -> Self {
556 self.spec.params.push(ParamSpec {
557 name: name.into(),
558 location: ParamLocation::Query,
559 required,
560 description: Some(description.into()),
561 param_type: "string".to_owned(),
562 });
563 self
564 }
565
566 pub fn query_param_typed(
568 mut self,
569 name: impl Into<String>,
570 required: bool,
571 description: impl Into<String>,
572 param_type: impl Into<String>,
573 ) -> Self {
574 self.spec.params.push(ParamSpec {
575 name: name.into(),
576 location: ParamLocation::Query,
577 required,
578 description: Some(description.into()),
579 param_type: param_type.into(),
580 });
581 self
582 }
583
584 pub fn json_request_schema(
587 mut self,
588 schema_name: impl Into<String>,
589 desc: impl Into<String>,
590 ) -> Self {
591 self.spec.request_body = Some(RequestBodySpec {
592 content_type: "application/json",
593 description: Some(desc.into()),
594 schema: RequestBodySchema::Ref {
595 schema_name: schema_name.into(),
596 },
597 required: true,
598 });
599 self
600 }
601
602 pub fn json_request_schema_no_desc(mut self, schema_name: impl Into<String>) -> Self {
605 self.spec.request_body = Some(RequestBodySpec {
606 content_type: "application/json",
607 description: None,
608 schema: RequestBodySchema::Ref {
609 schema_name: schema_name.into(),
610 },
611 required: true,
612 });
613 self
614 }
615
616 pub fn json_request<T>(
619 mut self,
620 registry: &dyn OpenApiRegistry,
621 desc: impl Into<String>,
622 ) -> Self
623 where
624 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::RequestApiDto + 'static,
625 {
626 let name = ensure_schema::<T>(registry);
627 self.spec.request_body = Some(RequestBodySpec {
628 content_type: "application/json",
629 description: Some(desc.into()),
630 schema: RequestBodySchema::Ref { schema_name: name },
631 required: true,
632 });
633 self
634 }
635
636 pub fn json_request_no_desc<T>(mut self, registry: &dyn OpenApiRegistry) -> Self
639 where
640 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::RequestApiDto + 'static,
641 {
642 let name = ensure_schema::<T>(registry);
643 self.spec.request_body = Some(RequestBodySpec {
644 content_type: "application/json",
645 description: None,
646 schema: RequestBodySchema::Ref { schema_name: name },
647 required: true,
648 });
649 self
650 }
651
652 pub fn request_optional(mut self) -> Self {
654 if let Some(rb) = &mut self.spec.request_body {
655 rb.required = false;
656 }
657 self
658 }
659
660 pub fn multipart_file_request(mut self, field_name: &str, description: Option<&str>) -> Self {
698 self.spec.request_body = Some(RequestBodySpec {
700 content_type: "multipart/form-data",
701 description: description
702 .map(|s| format!("{s} (expects field '{field_name}' with file data)")),
703 schema: RequestBodySchema::MultipartFile {
704 field_name: field_name.to_owned(),
705 },
706 required: true,
707 });
708
709 self.spec.allowed_request_content_types = Some(vec!["multipart/form-data"]);
711
712 self
713 }
714
715 pub fn octet_stream_request(mut self, description: Option<&str>) -> Self {
759 self.spec.request_body = Some(RequestBodySpec {
760 content_type: "application/octet-stream",
761 description: description.map(str::to_owned),
762 schema: RequestBodySchema::Binary,
763 required: true,
764 });
765
766 self.spec.allowed_request_content_types = Some(vec!["application/octet-stream"]);
768
769 self
770 }
771
772 pub fn allow_content_types(mut self, types: &[&'static str]) -> Self {
802 self.spec.allowed_request_content_types = Some(types.to_vec());
803 self
804 }
805}
806
807impl<H, R, S> OperationBuilder<H, R, S, AuthSet, LicenseNotSet>
809where
810 H: HandlerSlot<S>,
811{
812 pub fn require_license_features<F>(
825 mut self,
826 licenses: impl IntoIterator<Item = F>,
827 ) -> OperationBuilder<H, R, S, AuthSet, LicenseSet>
828 where
829 F: LicenseFeature,
830 {
831 let license_names: Vec<String> = licenses
832 .into_iter()
833 .map(|l| l.as_ref().to_owned())
834 .collect();
835
836 self.spec.license_requirement =
837 (!license_names.is_empty()).then_some(LicenseReqSpec { license_names });
838
839 OperationBuilder {
840 spec: self.spec,
841 method_router: self.method_router,
842 _has_handler: self._has_handler,
843 _has_response: self._has_response,
844 _state: self._state,
845 _auth_state: self._auth_state,
846 _license_state: PhantomData,
847 }
848 }
849
850 pub fn no_license_required(self) -> OperationBuilder<H, R, S, AuthSet, LicenseSet> {
858 OperationBuilder {
859 spec: self.spec,
860 method_router: self.method_router,
861 _has_handler: self._has_handler,
862 _has_response: self._has_response,
863 _state: self._state,
864 _auth_state: self._auth_state,
865 _license_state: PhantomData,
866 }
867 }
868}
869
870impl<H, R, S, L> OperationBuilder<H, R, S, AuthNotSet, L>
874where
875 H: HandlerSlot<S>,
876 L: LicenseState,
877{
878 pub fn authenticated(mut self) -> OperationBuilder<H, R, S, AuthSet, L> {
928 self.spec.authenticated = true;
929 self.spec.is_public = false;
930 OperationBuilder {
931 spec: self.spec,
932 method_router: self.method_router,
933 _has_handler: self._has_handler,
934 _has_response: self._has_response,
935 _state: self._state,
936 _auth_state: PhantomData,
937 _license_state: self._license_state,
938 }
939 }
940
941 pub fn public(mut self) -> OperationBuilder<H, R, S, AuthSet, LicenseSet> {
965 self.spec.is_public = true;
966 self.spec.authenticated = false;
967 OperationBuilder {
968 spec: self.spec,
969 method_router: self.method_router,
970 _has_handler: self._has_handler,
971 _has_response: self._has_response,
972 _state: self._state,
973 _auth_state: PhantomData,
974 _license_state: PhantomData,
975 }
976 }
977}
978
979impl<R, S, A, L> OperationBuilder<Missing, R, S, A, L>
983where
984 S: Clone + Send + Sync + 'static,
985 A: AuthState,
986 L: LicenseState,
987{
988 pub fn handler<F, T>(self, h: F) -> OperationBuilder<Present, R, S, A, L>
992 where
993 F: Handler<T, S> + Clone + Send + 'static,
994 T: 'static,
995 {
996 let method_router = match self.spec.method {
997 Method::GET => axum::routing::get(h),
998 Method::POST => axum::routing::post(h),
999 Method::PUT => axum::routing::put(h),
1000 Method::DELETE => axum::routing::delete(h),
1001 Method::PATCH => axum::routing::patch(h),
1002 _ => axum::routing::any(|| async { axum::http::StatusCode::METHOD_NOT_ALLOWED }),
1003 };
1004
1005 OperationBuilder {
1006 spec: self.spec,
1007 method_router, _has_handler: PhantomData::<Present>,
1009 _has_response: self._has_response,
1010 _state: self._state,
1011 _auth_state: self._auth_state,
1012 _license_state: self._license_state,
1013 }
1014 }
1015
1016 pub fn method_router(self, mr: MethodRouter<S>) -> OperationBuilder<Present, R, S, A, L> {
1019 OperationBuilder {
1020 spec: self.spec,
1021 method_router: mr, _has_handler: PhantomData::<Present>,
1023 _has_response: self._has_response,
1024 _state: self._state,
1025 _auth_state: self._auth_state,
1026 _license_state: self._license_state,
1027 }
1028 }
1029}
1030
1031impl<H, S, A, L> OperationBuilder<H, Missing, S, A, L>
1035where
1036 H: HandlerSlot<S>,
1037 A: AuthState,
1038 L: LicenseState,
1039{
1040 pub fn response(mut self, resp: ResponseSpec) -> OperationBuilder<H, Present, S, A, L> {
1042 self.spec.responses.push(resp);
1043 OperationBuilder {
1044 spec: self.spec,
1045 method_router: self.method_router,
1046 _has_handler: self._has_handler,
1047 _has_response: PhantomData::<Present>,
1048 _state: self._state,
1049 _auth_state: self._auth_state,
1050 _license_state: self._license_state,
1051 }
1052 }
1053
1054 pub fn json_response(
1056 mut self,
1057 status: http::StatusCode,
1058 description: impl Into<String>,
1059 ) -> OperationBuilder<H, Present, S, A, L> {
1060 self.spec.responses.push(ResponseSpec {
1061 status: status.as_u16(),
1062 content_type: "application/json",
1063 description: description.into(),
1064 schema_name: None,
1065 });
1066 OperationBuilder {
1067 spec: self.spec,
1068 method_router: self.method_router,
1069 _has_handler: self._has_handler,
1070 _has_response: PhantomData::<Present>,
1071 _state: self._state,
1072 _auth_state: self._auth_state,
1073 _license_state: self._license_state,
1074 }
1075 }
1076
1077 pub fn no_content_response(
1085 mut self,
1086 status: http::StatusCode,
1087 description: impl Into<String>,
1088 ) -> OperationBuilder<H, Present, S, A, L> {
1089 self.spec.responses.push(ResponseSpec {
1090 status: status.as_u16(),
1091 content_type: "",
1092 description: description.into(),
1093 schema_name: None,
1094 });
1095 OperationBuilder {
1096 spec: self.spec,
1097 method_router: self.method_router,
1098 _has_handler: self._has_handler,
1099 _has_response: PhantomData::<Present>,
1100 _state: self._state,
1101 _auth_state: self._auth_state,
1102 _license_state: self._license_state,
1103 }
1104 }
1105
1106 pub fn json_response_with_schema<T>(
1108 mut self,
1109 registry: &dyn OpenApiRegistry,
1110 status: http::StatusCode,
1111 description: impl Into<String>,
1112 ) -> OperationBuilder<H, Present, S, A, L>
1113 where
1114 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
1115 {
1116 let name = ensure_schema::<T>(registry);
1117 self.spec.responses.push(ResponseSpec {
1118 status: status.as_u16(),
1119 content_type: "application/json",
1120 description: description.into(),
1121 schema_name: Some(name),
1122 });
1123 OperationBuilder {
1124 spec: self.spec,
1125 method_router: self.method_router,
1126 _has_handler: self._has_handler,
1127 _has_response: PhantomData::<Present>,
1128 _state: self._state,
1129 _auth_state: self._auth_state,
1130 _license_state: self._license_state,
1131 }
1132 }
1133
1134 pub fn text_response(
1147 mut self,
1148 status: http::StatusCode,
1149 description: impl Into<String>,
1150 content_type: &'static str,
1151 ) -> OperationBuilder<H, Present, S, A, L> {
1152 self.spec.responses.push(ResponseSpec {
1153 status: status.as_u16(),
1154 content_type,
1155 description: description.into(),
1156 schema_name: None,
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 html_response(
1171 mut self,
1172 status: http::StatusCode,
1173 description: impl Into<String>,
1174 ) -> OperationBuilder<H, Present, S, A, L> {
1175 self.spec.responses.push(ResponseSpec {
1176 status: status.as_u16(),
1177 content_type: "text/html",
1178 description: description.into(),
1179 schema_name: None,
1180 });
1181 OperationBuilder {
1182 spec: self.spec,
1183 method_router: self.method_router,
1184 _has_handler: self._has_handler,
1185 _has_response: PhantomData::<Present>,
1186 _state: self._state,
1187 _auth_state: self._auth_state,
1188 _license_state: self._license_state,
1189 }
1190 }
1191
1192 pub fn problem_response(
1194 mut self,
1195 registry: &dyn OpenApiRegistry,
1196 status: http::StatusCode,
1197 description: impl Into<String>,
1198 ) -> OperationBuilder<H, Present, S, A, L> {
1199 let problem_name = ensure_schema::<crate::api::problem::Problem>(registry);
1201 self.spec.responses.push(ResponseSpec {
1202 status: status.as_u16(),
1203 content_type: problem::APPLICATION_PROBLEM_JSON,
1204 description: description.into(),
1205 schema_name: Some(problem_name),
1206 });
1207 OperationBuilder {
1208 spec: self.spec,
1209 method_router: self.method_router,
1210 _has_handler: self._has_handler,
1211 _has_response: PhantomData::<Present>,
1212 _state: self._state,
1213 _auth_state: self._auth_state,
1214 _license_state: self._license_state,
1215 }
1216 }
1217
1218 pub fn sse_json<T>(
1220 mut self,
1221 openapi: &dyn OpenApiRegistry,
1222 description: impl Into<String>,
1223 ) -> OperationBuilder<H, Present, S, A, L>
1224 where
1225 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
1226 {
1227 let name = ensure_schema::<T>(openapi);
1228 self.spec.responses.push(ResponseSpec {
1229 status: http::StatusCode::OK.as_u16(),
1230 content_type: "text/event-stream",
1231 description: description.into(),
1232 schema_name: Some(name),
1233 });
1234 OperationBuilder {
1235 spec: self.spec,
1236 method_router: self.method_router,
1237 _has_handler: self._has_handler,
1238 _has_response: PhantomData::<Present>,
1239 _state: self._state,
1240 _auth_state: self._auth_state,
1241 _license_state: self._license_state,
1242 }
1243 }
1244}
1245
1246impl<H, S, A, L> OperationBuilder<H, Present, S, A, L>
1250where
1251 H: HandlerSlot<S>,
1252 A: AuthState,
1253 L: LicenseState,
1254{
1255 pub fn json_response(
1257 mut self,
1258 status: http::StatusCode,
1259 description: impl Into<String>,
1260 ) -> Self {
1261 self.spec.responses.push(ResponseSpec {
1262 status: status.as_u16(),
1263 content_type: "application/json",
1264 description: description.into(),
1265 schema_name: None,
1266 });
1267 self
1268 }
1269
1270 pub fn no_content_response(
1272 mut self,
1273 status: http::StatusCode,
1274 description: impl Into<String>,
1275 ) -> Self {
1276 self.spec.responses.push(ResponseSpec {
1277 status: status.as_u16(),
1278 content_type: "",
1279 description: description.into(),
1280 schema_name: None,
1281 });
1282 self
1283 }
1284
1285 pub fn json_response_with_schema<T>(
1287 mut self,
1288 registry: &dyn OpenApiRegistry,
1289 status: http::StatusCode,
1290 description: impl Into<String>,
1291 ) -> Self
1292 where
1293 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
1294 {
1295 let name = ensure_schema::<T>(registry);
1296 self.spec.responses.push(ResponseSpec {
1297 status: status.as_u16(),
1298 content_type: "application/json",
1299 description: description.into(),
1300 schema_name: Some(name),
1301 });
1302 self
1303 }
1304
1305 pub fn text_response(
1318 mut self,
1319 status: http::StatusCode,
1320 description: impl Into<String>,
1321 content_type: &'static str,
1322 ) -> Self {
1323 self.spec.responses.push(ResponseSpec {
1324 status: status.as_u16(),
1325 content_type,
1326 description: description.into(),
1327 schema_name: None,
1328 });
1329 self
1330 }
1331
1332 pub fn html_response(
1334 mut self,
1335 status: http::StatusCode,
1336 description: impl Into<String>,
1337 ) -> Self {
1338 self.spec.responses.push(ResponseSpec {
1339 status: status.as_u16(),
1340 content_type: "text/html",
1341 description: description.into(),
1342 schema_name: None,
1343 });
1344 self
1345 }
1346
1347 pub fn problem_response(
1349 mut self,
1350 registry: &dyn OpenApiRegistry,
1351 status: http::StatusCode,
1352 description: impl Into<String>,
1353 ) -> Self {
1354 let problem_name = ensure_schema::<crate::api::problem::Problem>(registry);
1355 self.spec.responses.push(ResponseSpec {
1356 status: status.as_u16(),
1357 content_type: problem::APPLICATION_PROBLEM_JSON,
1358 description: description.into(),
1359 schema_name: Some(problem_name),
1360 });
1361 self
1362 }
1363
1364 pub fn sse_json<T>(
1366 mut self,
1367 openapi: &dyn OpenApiRegistry,
1368 description: impl Into<String>,
1369 ) -> Self
1370 where
1371 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
1372 {
1373 let name = ensure_schema::<T>(openapi);
1374 self.spec.responses.push(ResponseSpec {
1375 status: http::StatusCode::OK.as_u16(),
1376 content_type: "text/event-stream",
1377 description: description.into(),
1378 schema_name: Some(name),
1379 });
1380 self
1381 }
1382
1383 pub fn standard_errors(mut self, registry: &dyn OpenApiRegistry) -> Self {
1421 use http::StatusCode;
1422 let problem_name = ensure_schema::<crate::api::problem::Problem>(registry);
1423
1424 let standard_errors = [
1425 (StatusCode::BAD_REQUEST, "Bad Request"),
1426 (StatusCode::UNAUTHORIZED, "Unauthorized"),
1427 (StatusCode::FORBIDDEN, "Forbidden"),
1428 (StatusCode::NOT_FOUND, "Not Found"),
1429 (StatusCode::CONFLICT, "Conflict"),
1430 (StatusCode::UNPROCESSABLE_ENTITY, "Unprocessable Entity"),
1431 (StatusCode::TOO_MANY_REQUESTS, "Too Many Requests"),
1432 (StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error"),
1433 ];
1434
1435 for (status, description) in standard_errors {
1436 self.spec.responses.push(ResponseSpec {
1437 status: status.as_u16(),
1438 content_type: problem::APPLICATION_PROBLEM_JSON,
1439 description: description.to_owned(),
1440 schema_name: Some(problem_name.clone()),
1441 });
1442 }
1443
1444 self
1445 }
1446
1447 pub fn with_422_validation_error(mut self, registry: &dyn OpenApiRegistry) -> Self {
1484 let validation_error_name =
1485 ensure_schema::<crate::api::problem::ValidationErrorResponse>(registry);
1486
1487 self.spec.responses.push(ResponseSpec {
1488 status: http::StatusCode::UNPROCESSABLE_ENTITY.as_u16(),
1489 content_type: problem::APPLICATION_PROBLEM_JSON,
1490 description: "Validation Error".to_owned(),
1491 schema_name: Some(validation_error_name),
1492 });
1493
1494 self
1495 }
1496
1497 pub fn error_400(self, registry: &dyn OpenApiRegistry) -> Self {
1501 self.problem_response(registry, http::StatusCode::BAD_REQUEST, "Bad Request")
1502 }
1503
1504 pub fn error_401(self, registry: &dyn OpenApiRegistry) -> Self {
1508 self.problem_response(registry, http::StatusCode::UNAUTHORIZED, "Unauthorized")
1509 }
1510
1511 pub fn error_403(self, registry: &dyn OpenApiRegistry) -> Self {
1515 self.problem_response(registry, http::StatusCode::FORBIDDEN, "Forbidden")
1516 }
1517
1518 pub fn error_404(self, registry: &dyn OpenApiRegistry) -> Self {
1522 self.problem_response(registry, http::StatusCode::NOT_FOUND, "Not Found")
1523 }
1524
1525 pub fn error_409(self, registry: &dyn OpenApiRegistry) -> Self {
1529 self.problem_response(registry, http::StatusCode::CONFLICT, "Conflict")
1530 }
1531
1532 pub fn error_415(self, registry: &dyn OpenApiRegistry) -> Self {
1536 self.problem_response(
1537 registry,
1538 http::StatusCode::UNSUPPORTED_MEDIA_TYPE,
1539 "Unsupported Media Type",
1540 )
1541 }
1542
1543 pub fn error_422(self, registry: &dyn OpenApiRegistry) -> Self {
1547 self.problem_response(
1548 registry,
1549 http::StatusCode::UNPROCESSABLE_ENTITY,
1550 "Unprocessable Entity",
1551 )
1552 }
1553
1554 pub fn error_429(self, registry: &dyn OpenApiRegistry) -> Self {
1558 self.problem_response(
1559 registry,
1560 http::StatusCode::TOO_MANY_REQUESTS,
1561 "Too Many Requests",
1562 )
1563 }
1564
1565 pub fn error_500(self, registry: &dyn OpenApiRegistry) -> Self {
1569 self.problem_response(
1570 registry,
1571 http::StatusCode::INTERNAL_SERVER_ERROR,
1572 "Internal Server Error",
1573 )
1574 }
1575}
1576
1577impl<S> OperationBuilder<Present, Present, S, AuthSet, LicenseSet>
1581where
1582 S: Clone + Send + Sync + 'static,
1583{
1584 pub fn register(self, router: Router<S>, openapi: &dyn OpenApiRegistry) -> Router<S> {
1593 openapi.register_operation(&self.spec);
1596
1597 router.route(&self.spec.path, self.method_router)
1599 }
1600}
1601
1602#[cfg(test)]
1606#[cfg_attr(coverage_nightly, coverage(off))]
1607mod tests {
1608 use super::*;
1609 use axum::Json;
1610
1611 struct MockRegistry {
1613 operations: std::sync::Mutex<Vec<OperationSpec>>,
1614 schemas: std::sync::Mutex<Vec<String>>,
1615 }
1616
1617 impl MockRegistry {
1618 fn new() -> Self {
1619 Self {
1620 operations: std::sync::Mutex::new(Vec::new()),
1621 schemas: std::sync::Mutex::new(Vec::new()),
1622 }
1623 }
1624 }
1625
1626 enum TestLicenseFeatures {
1627 FeatureA,
1628 FeatureB,
1629 }
1630 impl AsRef<str> for TestLicenseFeatures {
1631 fn as_ref(&self) -> &str {
1632 match self {
1633 TestLicenseFeatures::FeatureA => "feature_a",
1634 TestLicenseFeatures::FeatureB => "feature_b",
1635 }
1636 }
1637 }
1638 impl LicenseFeature for TestLicenseFeatures {}
1639
1640 impl OpenApiRegistry for MockRegistry {
1641 fn register_operation(&self, spec: &OperationSpec) {
1642 if let Ok(mut ops) = self.operations.lock() {
1643 ops.push(spec.clone());
1644 }
1645 }
1646
1647 fn ensure_schema_raw(
1648 &self,
1649 name: &str,
1650 _schemas: Vec<(
1651 String,
1652 utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>,
1653 )>,
1654 ) -> String {
1655 let name = name.to_owned();
1656 if let Ok(mut s) = self.schemas.lock() {
1657 s.push(name.clone());
1658 }
1659 name
1660 }
1661
1662 fn as_any(&self) -> &dyn std::any::Any {
1663 self
1664 }
1665 }
1666
1667 async fn test_handler() -> Json<serde_json::Value> {
1668 Json(serde_json::json!({"status": "ok"}))
1669 }
1670
1671 #[modkit_macros::api_dto(request)]
1672 struct SampleDtoRequest;
1673
1674 #[modkit_macros::api_dto(response)]
1675 struct SampleDtoResponse;
1676
1677 #[test]
1678 fn builder_descriptive_methods() {
1679 let builder = OperationBuilder::<Missing, Missing, (), AuthNotSet>::get("/tests/v1/test")
1680 .operation_id("test.get")
1681 .summary("Test endpoint")
1682 .description("A test endpoint for validation")
1683 .tag("test")
1684 .path_param("id", "Test ID");
1685
1686 assert_eq!(builder.spec.method, Method::GET);
1687 assert_eq!(builder.spec.path, "/tests/v1/test");
1688 assert_eq!(builder.spec.operation_id, Some("test.get".to_owned()));
1689 assert_eq!(builder.spec.summary, Some("Test endpoint".to_owned()));
1690 assert_eq!(
1691 builder.spec.description,
1692 Some("A test endpoint for validation".to_owned())
1693 );
1694 assert_eq!(builder.spec.tags, vec!["test"]);
1695 assert_eq!(builder.spec.params.len(), 1);
1696 }
1697
1698 #[tokio::test]
1699 async fn builder_with_request_response_and_handler() {
1700 let registry = MockRegistry::new();
1701 let router = Router::new();
1702
1703 let _router = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
1704 .summary("Test endpoint")
1705 .json_request::<SampleDtoRequest>(®istry, "optional body") .public()
1707 .handler(test_handler)
1708 .json_response_with_schema::<SampleDtoResponse>(
1709 ®istry,
1710 http::StatusCode::OK,
1711 "Success response",
1712 ) .register(router, ®istry);
1714
1715 let ops = registry.operations.lock().unwrap();
1717 assert_eq!(ops.len(), 1);
1718 let op = &ops[0];
1719 assert_eq!(op.method, Method::POST);
1720 assert_eq!(op.path, "/tests/v1/test");
1721 assert!(op.request_body.is_some());
1722 assert!(op.request_body.as_ref().unwrap().required);
1723 assert_eq!(op.responses.len(), 1);
1724 assert_eq!(op.responses[0].status, 200);
1725
1726 let schemas = registry.schemas.lock().unwrap();
1728 assert!(!schemas.is_empty());
1729 }
1730
1731 #[test]
1732 fn convenience_constructors() {
1733 let get_builder =
1734 OperationBuilder::<Missing, Missing, (), AuthNotSet>::get("/tests/v1/get");
1735 assert_eq!(get_builder.spec.method, Method::GET);
1736 assert_eq!(get_builder.spec.path, "/tests/v1/get");
1737
1738 let post_builder =
1739 OperationBuilder::<Missing, Missing, (), AuthNotSet>::post("/tests/v1/post");
1740 assert_eq!(post_builder.spec.method, Method::POST);
1741 assert_eq!(post_builder.spec.path, "/tests/v1/post");
1742
1743 let put_builder =
1744 OperationBuilder::<Missing, Missing, (), AuthNotSet>::put("/tests/v1/put");
1745 assert_eq!(put_builder.spec.method, Method::PUT);
1746 assert_eq!(put_builder.spec.path, "/tests/v1/put");
1747
1748 let delete_builder =
1749 OperationBuilder::<Missing, Missing, (), AuthNotSet>::delete("/tests/v1/delete");
1750 assert_eq!(delete_builder.spec.method, Method::DELETE);
1751 assert_eq!(delete_builder.spec.path, "/tests/v1/delete");
1752
1753 let patch_builder =
1754 OperationBuilder::<Missing, Missing, (), AuthNotSet>::patch("/tests/v1/patch");
1755 assert_eq!(patch_builder.spec.method, Method::PATCH);
1756 assert_eq!(patch_builder.spec.path, "/tests/v1/patch");
1757 }
1758
1759 #[test]
1760 fn normalize_to_axum_path_should_normalize() {
1761 assert_eq!(
1763 normalize_to_axum_path("/tests/v1/users/{id}"),
1764 "/tests/v1/users/{id}"
1765 );
1766 assert_eq!(
1767 normalize_to_axum_path("/tests/v1/projects/{project_id}/items/{item_id}"),
1768 "/tests/v1/projects/{project_id}/items/{item_id}"
1769 );
1770 assert_eq!(
1771 normalize_to_axum_path("/tests/v1/simple"),
1772 "/tests/v1/simple"
1773 );
1774 assert_eq!(
1775 normalize_to_axum_path("/tests/v1/users/{id}/edit"),
1776 "/tests/v1/users/{id}/edit"
1777 );
1778 }
1779
1780 #[test]
1781 fn axum_to_openapi_path_should_convert() {
1782 assert_eq!(
1784 axum_to_openapi_path("/tests/v1/users/{id}"),
1785 "/tests/v1/users/{id}"
1786 );
1787 assert_eq!(
1788 axum_to_openapi_path("/tests/v1/projects/{project_id}/items/{item_id}"),
1789 "/tests/v1/projects/{project_id}/items/{item_id}"
1790 );
1791 assert_eq!(axum_to_openapi_path("/tests/v1/simple"), "/tests/v1/simple");
1792 assert_eq!(
1794 axum_to_openapi_path("/tests/v1/static/{*path}"),
1795 "/tests/v1/static/{path}"
1796 );
1797 assert_eq!(
1798 axum_to_openapi_path("/tests/v1/files/{*filepath}"),
1799 "/tests/v1/files/{filepath}"
1800 );
1801 }
1802
1803 #[test]
1804 fn path_normalization_in_constructors() {
1805 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/users/{id}");
1807 assert_eq!(builder.spec.path, "/tests/v1/users/{id}");
1808
1809 let builder = OperationBuilder::<Missing, Missing, ()>::post(
1810 "/tests/v1/projects/{project_id}/items/{item_id}",
1811 );
1812 assert_eq!(
1813 builder.spec.path,
1814 "/tests/v1/projects/{project_id}/items/{item_id}"
1815 );
1816
1817 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/simple");
1819 assert_eq!(builder.spec.path, "/tests/v1/simple");
1820 }
1821
1822 #[test]
1823 fn standard_errors() {
1824 let registry = MockRegistry::new();
1825 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1826 .public()
1827 .handler(test_handler)
1828 .json_response(http::StatusCode::OK, "Success")
1829 .standard_errors(®istry);
1830
1831 assert_eq!(builder.spec.responses.len(), 9);
1833
1834 let statuses: Vec<u16> = builder.spec.responses.iter().map(|r| r.status).collect();
1836 assert!(statuses.contains(&200)); assert!(statuses.contains(&400));
1838 assert!(statuses.contains(&401));
1839 assert!(statuses.contains(&403));
1840 assert!(statuses.contains(&404));
1841 assert!(statuses.contains(&409));
1842 assert!(statuses.contains(&422));
1843 assert!(statuses.contains(&429));
1844 assert!(statuses.contains(&500));
1845
1846 let error_responses: Vec<_> = builder
1848 .spec
1849 .responses
1850 .iter()
1851 .filter(|r| r.status >= 400)
1852 .collect();
1853
1854 for resp in error_responses {
1855 assert_eq!(
1856 resp.content_type,
1857 crate::api::problem::APPLICATION_PROBLEM_JSON
1858 );
1859 assert!(resp.schema_name.is_some());
1860 }
1861 }
1862
1863 #[test]
1864 fn authenticated() {
1865 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1866 .authenticated()
1867 .handler(test_handler)
1868 .json_response(http::StatusCode::OK, "Success");
1869
1870 assert!(builder.spec.authenticated);
1871 assert!(!builder.spec.is_public);
1872 }
1873
1874 #[test]
1875 fn require_license_features_none() {
1876 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1877 .authenticated()
1878 .require_license_features::<TestLicenseFeatures>([])
1879 .handler(|| async {})
1880 .json_response(http::StatusCode::OK, "OK");
1881
1882 assert!(builder.spec.license_requirement.is_none());
1883 }
1884
1885 #[test]
1886 fn no_license_required_transitions_and_allows_register() {
1887 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1888 .authenticated()
1889 .no_license_required()
1890 .handler(|| async {})
1891 .json_response(http::StatusCode::OK, "OK");
1892
1893 assert!(builder.spec.license_requirement.is_none());
1894 assert!(!builder.spec.is_public);
1895 }
1896
1897 #[test]
1898 fn require_license_features_one() {
1899 let feature = TestLicenseFeatures::FeatureA;
1900
1901 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1902 .authenticated()
1903 .require_license_features([&feature])
1904 .handler(|| async {})
1905 .json_response(http::StatusCode::OK, "OK");
1906
1907 let license_req = builder
1908 .spec
1909 .license_requirement
1910 .as_ref()
1911 .expect("Should have license requirement");
1912 assert_eq!(license_req.license_names, vec!["feature_a".to_owned()]);
1913 }
1914
1915 #[test]
1916 fn require_license_features_many() {
1917 let feature_a = TestLicenseFeatures::FeatureA;
1918 let feature_b = TestLicenseFeatures::FeatureB;
1919
1920 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1921 .authenticated()
1922 .require_license_features([&feature_a, &feature_b])
1923 .handler(|| async {})
1924 .json_response(http::StatusCode::OK, "OK");
1925
1926 let license_req = builder
1927 .spec
1928 .license_requirement
1929 .as_ref()
1930 .expect("Should have license requirement");
1931 assert_eq!(
1932 license_req.license_names,
1933 vec!["feature_a".to_owned(), "feature_b".to_owned()]
1934 );
1935 }
1936
1937 #[tokio::test]
1938 async fn public_does_not_require_license_features_and_can_register() {
1939 let registry = MockRegistry::new();
1940 let router = Router::new();
1941
1942 let _router = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1943 .public()
1944 .handler(test_handler)
1945 .json_response(http::StatusCode::OK, "Success")
1946 .register(router, ®istry);
1947
1948 let ops = registry.operations.lock().unwrap();
1949 assert_eq!(ops.len(), 1);
1950 assert!(ops[0].license_requirement.is_none());
1951 }
1952
1953 #[test]
1954 fn with_422_validation_error() {
1955 let registry = MockRegistry::new();
1956 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
1957 .public()
1958 .handler(test_handler)
1959 .json_response(http::StatusCode::CREATED, "Created")
1960 .with_422_validation_error(®istry);
1961
1962 assert_eq!(builder.spec.responses.len(), 2);
1964
1965 let validation_response = builder
1966 .spec
1967 .responses
1968 .iter()
1969 .find(|r| r.status == 422)
1970 .expect("Should have 422 response");
1971
1972 assert_eq!(validation_response.description, "Validation Error");
1973 assert_eq!(
1974 validation_response.content_type,
1975 crate::api::problem::APPLICATION_PROBLEM_JSON
1976 );
1977 assert!(validation_response.schema_name.is_some());
1978 }
1979
1980 #[test]
1981 fn allow_content_types_with_existing_request_body() {
1982 let registry = MockRegistry::new();
1983 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
1984 .json_request::<SampleDtoRequest>(®istry, "Test request")
1985 .allow_content_types(&["application/json", "application/xml"])
1986 .public()
1987 .handler(test_handler)
1988 .json_response(http::StatusCode::OK, "Success");
1989
1990 assert!(builder.spec.request_body.is_some());
1992 assert!(builder.spec.allowed_request_content_types.is_some());
1993 let allowed = builder.spec.allowed_request_content_types.as_ref().unwrap();
1994 assert_eq!(allowed.len(), 2);
1995 assert!(allowed.contains(&"application/json"));
1996 assert!(allowed.contains(&"application/xml"));
1997 }
1998
1999 #[test]
2000 fn allow_content_types_without_existing_request_body() {
2001 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
2002 .allow_content_types(&["multipart/form-data"])
2003 .public()
2004 .handler(test_handler)
2005 .json_response(http::StatusCode::OK, "Success");
2006
2007 assert!(builder.spec.request_body.is_none());
2009 assert!(builder.spec.allowed_request_content_types.is_some());
2010 let allowed = builder.spec.allowed_request_content_types.as_ref().unwrap();
2011 assert_eq!(allowed.len(), 1);
2012 assert!(allowed.contains(&"multipart/form-data"));
2013 }
2014
2015 #[test]
2016 fn allow_content_types_can_be_chained() {
2017 let registry = MockRegistry::new();
2018 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
2019 .operation_id("test.post")
2020 .summary("Test endpoint")
2021 .json_request::<SampleDtoRequest>(®istry, "Test request")
2022 .allow_content_types(&["application/json"])
2023 .public()
2024 .handler(test_handler)
2025 .json_response(http::StatusCode::OK, "Success")
2026 .problem_response(
2027 ®istry,
2028 http::StatusCode::UNSUPPORTED_MEDIA_TYPE,
2029 "Unsupported Media Type",
2030 );
2031
2032 assert_eq!(builder.spec.operation_id, Some("test.post".to_owned()));
2033 assert!(builder.spec.request_body.is_some());
2034 assert!(builder.spec.allowed_request_content_types.is_some());
2035 assert_eq!(builder.spec.responses.len(), 2);
2036 }
2037
2038 #[test]
2039 fn multipart_file_request() {
2040 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/upload")
2041 .operation_id("test.upload")
2042 .summary("Upload file")
2043 .multipart_file_request("file", Some("Upload a file"))
2044 .public()
2045 .handler(test_handler)
2046 .json_response(http::StatusCode::OK, "Success");
2047
2048 assert!(builder.spec.request_body.is_some());
2050 let rb = builder.spec.request_body.as_ref().unwrap();
2051 assert_eq!(rb.content_type, "multipart/form-data");
2052 assert!(rb.description.is_some());
2053 assert!(rb.description.as_ref().unwrap().contains("file"));
2054 assert!(rb.required);
2055
2056 assert_eq!(
2058 rb.schema,
2059 RequestBodySchema::MultipartFile {
2060 field_name: "file".to_owned()
2061 }
2062 );
2063
2064 assert!(builder.spec.allowed_request_content_types.is_some());
2066 let allowed = builder.spec.allowed_request_content_types.as_ref().unwrap();
2067 assert_eq!(allowed.len(), 1);
2068 assert!(allowed.contains(&"multipart/form-data"));
2069 }
2070
2071 #[test]
2072 fn multipart_file_request_without_description() {
2073 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/upload")
2074 .multipart_file_request("file", None)
2075 .public()
2076 .handler(test_handler)
2077 .json_response(http::StatusCode::OK, "Success");
2078
2079 assert!(builder.spec.request_body.is_some());
2080 let rb = builder.spec.request_body.as_ref().unwrap();
2081 assert_eq!(rb.content_type, "multipart/form-data");
2082 assert!(rb.description.is_none());
2083 assert_eq!(
2084 rb.schema,
2085 RequestBodySchema::MultipartFile {
2086 field_name: "file".to_owned()
2087 }
2088 );
2089 }
2090
2091 #[test]
2092 fn octet_stream_request() {
2093 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/upload")
2094 .operation_id("test.upload")
2095 .summary("Upload raw file")
2096 .octet_stream_request(Some("Raw file bytes"))
2097 .public()
2098 .handler(test_handler)
2099 .json_response(http::StatusCode::OK, "Success");
2100
2101 assert!(builder.spec.request_body.is_some());
2103 let rb = builder.spec.request_body.as_ref().unwrap();
2104 assert_eq!(rb.content_type, "application/octet-stream");
2105 assert_eq!(rb.description, Some("Raw file bytes".to_owned()));
2106 assert!(rb.required);
2107
2108 assert_eq!(rb.schema, RequestBodySchema::Binary);
2110
2111 assert!(builder.spec.allowed_request_content_types.is_some());
2113 let allowed = builder.spec.allowed_request_content_types.as_ref().unwrap();
2114 assert_eq!(allowed.len(), 1);
2115 assert!(allowed.contains(&"application/octet-stream"));
2116 }
2117
2118 #[test]
2119 fn octet_stream_request_without_description() {
2120 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/upload")
2121 .octet_stream_request(None)
2122 .public()
2123 .handler(test_handler)
2124 .json_response(http::StatusCode::OK, "Success");
2125
2126 assert!(builder.spec.request_body.is_some());
2127 let rb = builder.spec.request_body.as_ref().unwrap();
2128 assert_eq!(rb.content_type, "application/octet-stream");
2129 assert!(rb.description.is_none());
2130 assert_eq!(rb.schema, RequestBodySchema::Binary);
2131 }
2132
2133 #[test]
2134 fn json_request_uses_ref_schema() {
2135 let registry = MockRegistry::new();
2136 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
2137 .json_request::<SampleDtoRequest>(®istry, "Test request body")
2138 .public()
2139 .handler(test_handler)
2140 .json_response(http::StatusCode::OK, "Success");
2141
2142 assert!(builder.spec.request_body.is_some());
2143 let rb = builder.spec.request_body.as_ref().unwrap();
2144 assert_eq!(rb.content_type, "application/json");
2145
2146 match &rb.schema {
2148 RequestBodySchema::Ref { schema_name } => {
2149 assert!(!schema_name.is_empty());
2150 }
2151 _ => panic!("Expected RequestBodySchema::Ref for JSON request"),
2152 }
2153 }
2154
2155 #[test]
2156 fn response_content_types_must_not_contain_parameters() {
2157 let registry = MockRegistry::new();
2160 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
2161 .operation_id("test.content_type_purity")
2162 .summary("Test response content types")
2163 .json_request::<SampleDtoRequest>(®istry, "Test")
2164 .public()
2165 .handler(test_handler)
2166 .text_response(http::StatusCode::OK, "Text", "text/plain")
2167 .text_response(http::StatusCode::OK, "Markdown", "text/markdown")
2168 .html_response(http::StatusCode::OK, "HTML")
2169 .json_response(http::StatusCode::OK, "JSON")
2170 .problem_response(®istry, http::StatusCode::BAD_REQUEST, "Error");
2171
2172 for response in &builder.spec.responses {
2174 assert!(
2175 !response.content_type.contains(';'),
2176 "Response content_type '{}' must not contain parameters. \
2177 Use pure media type without charset or other parameters. \
2178 OpenAPI media type keys cannot include parameters.",
2179 response.content_type
2180 );
2181 }
2182 }
2183}