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 AuthReqResource: AsRef<str> {}
142
143pub trait AuthReqAction: AsRef<str> {}
144
145pub trait LicenseFeature: AsRef<str> {}
146
147impl<T: LicenseFeature + ?Sized> LicenseFeature for &T {}
148
149#[derive(Clone, Debug, PartialEq, Eq)]
150pub enum ParamLocation {
151 Path,
152 Query,
153 Header,
154 Cookie,
155}
156
157#[derive(Clone, Debug, PartialEq, Eq)]
159pub enum RequestBodySchema {
160 Ref { schema_name: String },
162 MultipartFile { field_name: String },
164 Binary,
167 InlineObject,
169}
170
171#[derive(Clone, Debug)]
173pub struct RequestBodySpec {
174 pub content_type: &'static str,
175 pub description: Option<String>,
176 pub schema: RequestBodySchema,
178 pub required: bool,
180}
181
182#[derive(Clone, Debug)]
184pub struct ResponseSpec {
185 pub status: u16,
186 pub content_type: &'static str,
187 pub description: String,
188 pub schema_name: Option<String>,
190}
191
192#[derive(Clone, Debug)]
194pub struct OperationSecRequirement {
195 pub resource: String,
196 pub action: String,
197}
198
199#[derive(Clone, Debug)]
201pub struct LicenseReqSpec {
202 pub license_names: Vec<String>,
203}
204
205#[derive(Clone, Debug)]
207pub struct OperationSpec {
208 pub method: Method,
209 pub path: String,
210 pub operation_id: Option<String>,
211 pub summary: Option<String>,
212 pub description: Option<String>,
213 pub tags: Vec<String>,
214 pub params: Vec<ParamSpec>,
215 pub request_body: Option<RequestBodySpec>,
216 pub responses: Vec<ResponseSpec>,
217 pub handler_id: String,
219 pub sec_requirement: Option<OperationSecRequirement>,
221 pub is_public: bool,
223 pub rate_limit: Option<RateLimitSpec>,
225 pub allowed_request_content_types: Option<Vec<&'static str>>,
231 pub vendor_extensions: VendorExtensions,
233 pub license_requirement: Option<LicenseReqSpec>,
234}
235
236#[derive(Clone, Debug, Default, Deserialize, Serialize)]
237pub struct VendorExtensions {
238 #[serde(rename = "x-odata-filter", skip_serializing_if = "Option::is_none")]
239 pub x_odata_filter: Option<ODataPagination<BTreeMap<String, Vec<String>>>>,
240 #[serde(rename = "x-odata-orderby", skip_serializing_if = "Option::is_none")]
241 pub x_odata_orderby: Option<ODataPagination<Vec<String>>>,
242}
243
244#[derive(Clone, Debug, Default, Deserialize, Serialize)]
245pub struct ODataPagination<T> {
246 #[serde(rename = "allowedFields")]
247 pub allowed_fields: T,
248}
249
250#[derive(Clone, Debug, Default)]
252pub struct RateLimitSpec {
253 pub rps: u32,
255 pub burst: u32,
257 pub in_flight: u32,
259}
260
261#[derive(Clone, Debug, Deserialize, Serialize, Default)]
262#[serde(rename_all = "camelCase")]
263pub struct XPagination {
264 pub filter_fields: BTreeMap<String, Vec<String>>,
265 pub order_by: Vec<String>,
266}
267
268pub trait OperationBuilderODataExt<S, H, R> {
270 #[must_use]
272 fn with_odata_filter<T>(self) -> Self
273 where
274 T: modkit_odata::filter::FilterField;
275
276 #[must_use]
278 fn with_odata_select(self) -> Self;
279
280 #[must_use]
282 fn with_odata_orderby<T>(self) -> Self
283 where
284 T: modkit_odata::filter::FilterField;
285}
286
287impl<S, H, R, A, L> OperationBuilderODataExt<S, H, R> for OperationBuilder<H, R, S, A, L>
288where
289 H: HandlerSlot<S>,
290 A: AuthState,
291 L: LicenseState,
292{
293 fn with_odata_filter<T>(mut self) -> Self
294 where
295 T: modkit_odata::filter::FilterField,
296 {
297 use modkit_odata::filter::FieldKind;
298 use std::fmt::Write as _;
299
300 let mut filter = self
301 .spec
302 .vendor_extensions
303 .x_odata_filter
304 .unwrap_or_default();
305
306 let mut description = "OData v4 filter expression".to_owned();
307 for field in T::FIELDS {
308 let name = field.name().to_owned();
309 let kind = field.kind();
310
311 let ops: Vec<String> = match kind {
312 FieldKind::String => vec!["eq", "ne", "contains", "startswith", "endswith", "in"],
313 FieldKind::Uuid => vec!["eq", "ne", "in"],
314 FieldKind::Bool => vec!["eq", "ne"],
315 FieldKind::I64
316 | FieldKind::F64
317 | FieldKind::Decimal
318 | FieldKind::DateTimeUtc
319 | FieldKind::Date
320 | FieldKind::Time => {
321 vec!["eq", "ne", "gt", "ge", "lt", "le", "in"]
322 }
323 }
324 .into_iter()
325 .map(String::from)
326 .collect();
327
328 _ = write!(description, "\n- {}: {}", name, ops.join("|"));
329 filter.allowed_fields.insert(name.clone(), ops);
330 }
331 self.spec.params.push(ParamSpec {
332 name: "$filter".to_owned(),
333 location: ParamLocation::Query,
334 required: false,
335 description: Some(description),
336 param_type: "string".to_owned(),
337 });
338 self.spec.vendor_extensions.x_odata_filter = Some(filter);
339 self
340 }
341
342 fn with_odata_select(mut self) -> Self {
343 self.spec.params.push(ParamSpec {
344 name: "$select".to_owned(),
345 location: ParamLocation::Query,
346 required: false,
347 description: Some("OData v4 select expression".to_owned()),
348 param_type: "string".to_owned(),
349 });
350 self
351 }
352
353 fn with_odata_orderby<T>(mut self) -> Self
354 where
355 T: modkit_odata::filter::FilterField,
356 {
357 use std::fmt::Write as _;
358 let mut order_by = self
359 .spec
360 .vendor_extensions
361 .x_odata_orderby
362 .unwrap_or_default();
363 let mut description = "OData v4 orderby expression".to_owned();
364 for field in T::FIELDS {
365 let name = field.name().to_owned();
366
367 let asc = format!("{name} asc");
369 let desc = format!("{name} desc");
370
371 _ = write!(description, "\n- {asc}\n- {desc}");
372 if !order_by.allowed_fields.contains(&asc) {
373 order_by.allowed_fields.push(asc);
374 }
375 if !order_by.allowed_fields.contains(&desc) {
376 order_by.allowed_fields.push(desc);
377 }
378 }
379 self.spec.params.push(ParamSpec {
380 name: "$orderby".to_owned(),
381 location: ParamLocation::Query,
382 required: false,
383 description: Some(description),
384 param_type: "string".to_owned(),
385 });
386 self.spec.vendor_extensions.x_odata_orderby = Some(order_by);
387 self
388 }
389}
390
391pub use crate::api::openapi_registry::{OpenApiRegistry, ensure_schema};
393
394#[must_use]
403pub struct OperationBuilder<H = Missing, R = Missing, S = (), A = AuthNotSet, L = LicenseNotSet>
404where
405 H: HandlerSlot<S>,
406 A: AuthState,
407 L: LicenseState,
408{
409 spec: OperationSpec,
410 method_router: <H as HandlerSlot<S>>::Slot,
411 _has_handler: PhantomData<H>,
412 _has_response: PhantomData<R>,
413 #[allow(clippy::type_complexity)]
414 _state: PhantomData<fn() -> S>, _auth_state: PhantomData<A>,
416 _license_state: PhantomData<L>,
417}
418
419impl<S> OperationBuilder<Missing, Missing, S, AuthNotSet> {
423 pub fn new(method: Method, path: impl Into<String>) -> Self {
425 let path_str = path.into();
426 let handler_id = format!(
427 "{}:{}",
428 method.as_str().to_lowercase(),
429 path_str.replace(['/', '{', '}'], "_")
430 );
431
432 Self {
433 spec: OperationSpec {
434 method,
435 path: path_str,
436 operation_id: None,
437 summary: None,
438 description: None,
439 tags: Vec::new(),
440 params: Vec::new(),
441 request_body: None,
442 responses: Vec::new(),
443 handler_id,
444 sec_requirement: None,
445 is_public: false,
446 rate_limit: None,
447 allowed_request_content_types: None,
448 vendor_extensions: VendorExtensions::default(),
449 license_requirement: None,
450 },
451 method_router: (), _has_handler: PhantomData,
453 _has_response: PhantomData,
454 _state: PhantomData,
455 _auth_state: PhantomData,
456 _license_state: PhantomData,
457 }
458 }
459
460 pub fn get(path: impl Into<String>) -> Self {
462 let path_str = path.into();
463 Self::new(Method::GET, normalize_to_axum_path(&path_str))
464 }
465
466 pub fn post(path: impl Into<String>) -> Self {
468 let path_str = path.into();
469 Self::new(Method::POST, normalize_to_axum_path(&path_str))
470 }
471
472 pub fn put(path: impl Into<String>) -> Self {
474 let path_str = path.into();
475 Self::new(Method::PUT, normalize_to_axum_path(&path_str))
476 }
477
478 pub fn delete(path: impl Into<String>) -> Self {
480 let path_str = path.into();
481 Self::new(Method::DELETE, normalize_to_axum_path(&path_str))
482 }
483
484 pub fn patch(path: impl Into<String>) -> Self {
486 let path_str = path.into();
487 Self::new(Method::PATCH, normalize_to_axum_path(&path_str))
488 }
489}
490
491impl<H, R, S, A, L> OperationBuilder<H, R, S, A, L>
495where
496 H: HandlerSlot<S>,
497 A: AuthState,
498 L: LicenseState,
499{
500 pub fn spec(&self) -> &OperationSpec {
502 &self.spec
503 }
504
505 pub fn operation_id(mut self, id: impl Into<String>) -> Self {
507 self.spec.operation_id = Some(id.into());
508 self
509 }
510
511 pub fn require_rate_limit(&mut self, rps: u32, burst: u32, in_flight: u32) -> &mut Self {
514 self.spec.rate_limit = Some(RateLimitSpec {
515 rps,
516 burst,
517 in_flight,
518 });
519 self
520 }
521
522 pub fn summary(mut self, text: impl Into<String>) -> Self {
524 self.spec.summary = Some(text.into());
525 self
526 }
527
528 pub fn description(mut self, text: impl Into<String>) -> Self {
530 self.spec.description = Some(text.into());
531 self
532 }
533
534 pub fn tag(mut self, tag: impl Into<String>) -> Self {
536 self.spec.tags.push(tag.into());
537 self
538 }
539
540 pub fn param(mut self, param: ParamSpec) -> Self {
542 self.spec.params.push(param);
543 self
544 }
545
546 pub fn path_param(mut self, name: impl Into<String>, description: impl Into<String>) -> Self {
548 self.spec.params.push(ParamSpec {
549 name: name.into(),
550 location: ParamLocation::Path,
551 required: true,
552 description: Some(description.into()),
553 param_type: "string".to_owned(),
554 });
555 self
556 }
557
558 pub fn query_param(
560 mut self,
561 name: impl Into<String>,
562 required: bool,
563 description: impl Into<String>,
564 ) -> Self {
565 self.spec.params.push(ParamSpec {
566 name: name.into(),
567 location: ParamLocation::Query,
568 required,
569 description: Some(description.into()),
570 param_type: "string".to_owned(),
571 });
572 self
573 }
574
575 pub fn query_param_typed(
577 mut self,
578 name: impl Into<String>,
579 required: bool,
580 description: impl Into<String>,
581 param_type: impl Into<String>,
582 ) -> Self {
583 self.spec.params.push(ParamSpec {
584 name: name.into(),
585 location: ParamLocation::Query,
586 required,
587 description: Some(description.into()),
588 param_type: param_type.into(),
589 });
590 self
591 }
592
593 pub fn json_request_schema(
596 mut self,
597 schema_name: impl Into<String>,
598 desc: impl Into<String>,
599 ) -> Self {
600 self.spec.request_body = Some(RequestBodySpec {
601 content_type: "application/json",
602 description: Some(desc.into()),
603 schema: RequestBodySchema::Ref {
604 schema_name: schema_name.into(),
605 },
606 required: true,
607 });
608 self
609 }
610
611 pub fn json_request_schema_no_desc(mut self, schema_name: impl Into<String>) -> Self {
614 self.spec.request_body = Some(RequestBodySpec {
615 content_type: "application/json",
616 description: None,
617 schema: RequestBodySchema::Ref {
618 schema_name: schema_name.into(),
619 },
620 required: true,
621 });
622 self
623 }
624
625 pub fn json_request<T>(
628 mut self,
629 registry: &dyn OpenApiRegistry,
630 desc: impl Into<String>,
631 ) -> Self
632 where
633 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::RequestApiDto + 'static,
634 {
635 let name = ensure_schema::<T>(registry);
636 self.spec.request_body = Some(RequestBodySpec {
637 content_type: "application/json",
638 description: Some(desc.into()),
639 schema: RequestBodySchema::Ref { schema_name: name },
640 required: true,
641 });
642 self
643 }
644
645 pub fn json_request_no_desc<T>(mut self, registry: &dyn OpenApiRegistry) -> Self
648 where
649 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::RequestApiDto + 'static,
650 {
651 let name = ensure_schema::<T>(registry);
652 self.spec.request_body = Some(RequestBodySpec {
653 content_type: "application/json",
654 description: None,
655 schema: RequestBodySchema::Ref { schema_name: name },
656 required: true,
657 });
658 self
659 }
660
661 pub fn request_optional(mut self) -> Self {
663 if let Some(rb) = &mut self.spec.request_body {
664 rb.required = false;
665 }
666 self
667 }
668
669 pub fn multipart_file_request(mut self, field_name: &str, description: Option<&str>) -> Self {
707 self.spec.request_body = Some(RequestBodySpec {
709 content_type: "multipart/form-data",
710 description: description
711 .map(|s| format!("{s} (expects field '{field_name}' with file data)")),
712 schema: RequestBodySchema::MultipartFile {
713 field_name: field_name.to_owned(),
714 },
715 required: true,
716 });
717
718 self.spec.allowed_request_content_types = Some(vec!["multipart/form-data"]);
720
721 self
722 }
723
724 pub fn octet_stream_request(mut self, description: Option<&str>) -> Self {
768 self.spec.request_body = Some(RequestBodySpec {
769 content_type: "application/octet-stream",
770 description: description.map(ToString::to_string),
771 schema: RequestBodySchema::Binary,
772 required: true,
773 });
774
775 self.spec.allowed_request_content_types = Some(vec!["application/octet-stream"]);
777
778 self
779 }
780
781 pub fn allow_content_types(mut self, types: &[&'static str]) -> Self {
811 self.spec.allowed_request_content_types = Some(types.to_vec());
812 self
813 }
814}
815
816impl<H, R, S> OperationBuilder<H, R, S, AuthSet, LicenseNotSet>
818where
819 H: HandlerSlot<S>,
820{
821 pub fn require_license_features<F>(
834 mut self,
835 licenses: impl IntoIterator<Item = F>,
836 ) -> OperationBuilder<H, R, S, AuthSet, LicenseSet>
837 where
838 F: LicenseFeature,
839 {
840 let license_names: Vec<String> = licenses
841 .into_iter()
842 .map(|l| l.as_ref().to_owned())
843 .collect();
844
845 self.spec.license_requirement =
846 (!license_names.is_empty()).then_some(LicenseReqSpec { license_names });
847
848 OperationBuilder {
849 spec: self.spec,
850 method_router: self.method_router,
851 _has_handler: self._has_handler,
852 _has_response: self._has_response,
853 _state: self._state,
854 _auth_state: self._auth_state,
855 _license_state: PhantomData,
856 }
857 }
858}
859
860impl<H, R, S, L> OperationBuilder<H, R, S, AuthNotSet, L>
864where
865 H: HandlerSlot<S>,
866 L: LicenseState,
867{
868 pub fn require_auth(
945 mut self,
946 resource: &impl AuthReqResource,
947 action: &impl AuthReqAction,
948 ) -> OperationBuilder<H, R, S, AuthSet, L> {
949 self.spec.sec_requirement = Some(OperationSecRequirement {
950 resource: resource.as_ref().into(),
951 action: action.as_ref().into(),
952 });
953 self.spec.is_public = false;
954 OperationBuilder {
955 spec: self.spec,
956 method_router: self.method_router,
957 _has_handler: self._has_handler,
958 _has_response: self._has_response,
959 _state: self._state,
960 _auth_state: PhantomData,
961 _license_state: self._license_state,
962 }
963 }
964
965 pub fn public(mut self) -> OperationBuilder<H, R, S, AuthSet, LicenseSet> {
989 self.spec.is_public = true;
990 self.spec.sec_requirement = None;
991 OperationBuilder {
992 spec: self.spec,
993 method_router: self.method_router,
994 _has_handler: self._has_handler,
995 _has_response: self._has_response,
996 _state: self._state,
997 _auth_state: PhantomData,
998 _license_state: PhantomData,
999 }
1000 }
1001}
1002
1003impl<R, S, A, L> OperationBuilder<Missing, R, S, A, L>
1007where
1008 S: Clone + Send + Sync + 'static,
1009 A: AuthState,
1010 L: LicenseState,
1011{
1012 pub fn handler<F, T>(self, h: F) -> OperationBuilder<Present, R, S, A, L>
1016 where
1017 F: Handler<T, S> + Clone + Send + 'static,
1018 T: 'static,
1019 {
1020 let method_router = match self.spec.method {
1021 Method::GET => axum::routing::get(h),
1022 Method::POST => axum::routing::post(h),
1023 Method::PUT => axum::routing::put(h),
1024 Method::DELETE => axum::routing::delete(h),
1025 Method::PATCH => axum::routing::patch(h),
1026 _ => axum::routing::any(|| async { axum::http::StatusCode::METHOD_NOT_ALLOWED }),
1027 };
1028
1029 OperationBuilder {
1030 spec: self.spec,
1031 method_router, _has_handler: PhantomData::<Present>,
1033 _has_response: self._has_response,
1034 _state: self._state,
1035 _auth_state: self._auth_state,
1036 _license_state: self._license_state,
1037 }
1038 }
1039
1040 pub fn method_router(self, mr: MethodRouter<S>) -> OperationBuilder<Present, R, S, A, L> {
1043 OperationBuilder {
1044 spec: self.spec,
1045 method_router: mr, _has_handler: PhantomData::<Present>,
1047 _has_response: self._has_response,
1048 _state: self._state,
1049 _auth_state: self._auth_state,
1050 _license_state: self._license_state,
1051 }
1052 }
1053}
1054
1055impl<H, S, A, L> OperationBuilder<H, Missing, S, A, L>
1059where
1060 H: HandlerSlot<S>,
1061 A: AuthState,
1062 L: LicenseState,
1063{
1064 pub fn response(mut self, resp: ResponseSpec) -> OperationBuilder<H, Present, S, A, L> {
1066 self.spec.responses.push(resp);
1067 OperationBuilder {
1068 spec: self.spec,
1069 method_router: self.method_router,
1070 _has_handler: self._has_handler,
1071 _has_response: PhantomData::<Present>,
1072 _state: self._state,
1073 _auth_state: self._auth_state,
1074 _license_state: self._license_state,
1075 }
1076 }
1077
1078 pub fn json_response(
1080 mut self,
1081 status: http::StatusCode,
1082 description: impl Into<String>,
1083 ) -> OperationBuilder<H, Present, S, A, L> {
1084 self.spec.responses.push(ResponseSpec {
1085 status: status.as_u16(),
1086 content_type: "application/json",
1087 description: description.into(),
1088 schema_name: None,
1089 });
1090 OperationBuilder {
1091 spec: self.spec,
1092 method_router: self.method_router,
1093 _has_handler: self._has_handler,
1094 _has_response: PhantomData::<Present>,
1095 _state: self._state,
1096 _auth_state: self._auth_state,
1097 _license_state: self._license_state,
1098 }
1099 }
1100
1101 pub fn json_response_with_schema<T>(
1103 mut self,
1104 registry: &dyn OpenApiRegistry,
1105 status: http::StatusCode,
1106 description: impl Into<String>,
1107 ) -> OperationBuilder<H, Present, S, A, L>
1108 where
1109 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
1110 {
1111 let name = ensure_schema::<T>(registry);
1112 self.spec.responses.push(ResponseSpec {
1113 status: status.as_u16(),
1114 content_type: "application/json",
1115 description: description.into(),
1116 schema_name: Some(name),
1117 });
1118 OperationBuilder {
1119 spec: self.spec,
1120 method_router: self.method_router,
1121 _has_handler: self._has_handler,
1122 _has_response: PhantomData::<Present>,
1123 _state: self._state,
1124 _auth_state: self._auth_state,
1125 _license_state: self._license_state,
1126 }
1127 }
1128
1129 pub fn text_response(
1142 mut self,
1143 status: http::StatusCode,
1144 description: impl Into<String>,
1145 content_type: &'static str,
1146 ) -> OperationBuilder<H, Present, S, A, L> {
1147 self.spec.responses.push(ResponseSpec {
1148 status: status.as_u16(),
1149 content_type,
1150 description: description.into(),
1151 schema_name: None,
1152 });
1153 OperationBuilder {
1154 spec: self.spec,
1155 method_router: self.method_router,
1156 _has_handler: self._has_handler,
1157 _has_response: PhantomData::<Present>,
1158 _state: self._state,
1159 _auth_state: self._auth_state,
1160 _license_state: self._license_state,
1161 }
1162 }
1163
1164 pub fn html_response(
1166 mut self,
1167 status: http::StatusCode,
1168 description: impl Into<String>,
1169 ) -> OperationBuilder<H, Present, S, A, L> {
1170 self.spec.responses.push(ResponseSpec {
1171 status: status.as_u16(),
1172 content_type: "text/html",
1173 description: description.into(),
1174 schema_name: None,
1175 });
1176 OperationBuilder {
1177 spec: self.spec,
1178 method_router: self.method_router,
1179 _has_handler: self._has_handler,
1180 _has_response: PhantomData::<Present>,
1181 _state: self._state,
1182 _auth_state: self._auth_state,
1183 _license_state: self._license_state,
1184 }
1185 }
1186
1187 pub fn problem_response(
1189 mut self,
1190 registry: &dyn OpenApiRegistry,
1191 status: http::StatusCode,
1192 description: impl Into<String>,
1193 ) -> OperationBuilder<H, Present, S, A, L> {
1194 let problem_name = ensure_schema::<crate::api::problem::Problem>(registry);
1196 self.spec.responses.push(ResponseSpec {
1197 status: status.as_u16(),
1198 content_type: problem::APPLICATION_PROBLEM_JSON,
1199 description: description.into(),
1200 schema_name: Some(problem_name),
1201 });
1202 OperationBuilder {
1203 spec: self.spec,
1204 method_router: self.method_router,
1205 _has_handler: self._has_handler,
1206 _has_response: PhantomData::<Present>,
1207 _state: self._state,
1208 _auth_state: self._auth_state,
1209 _license_state: self._license_state,
1210 }
1211 }
1212
1213 pub fn sse_json<T>(
1215 mut self,
1216 openapi: &dyn OpenApiRegistry,
1217 description: impl Into<String>,
1218 ) -> OperationBuilder<H, Present, S, A, L>
1219 where
1220 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
1221 {
1222 let name = ensure_schema::<T>(openapi);
1223 self.spec.responses.push(ResponseSpec {
1224 status: http::StatusCode::OK.as_u16(),
1225 content_type: "text/event-stream",
1226 description: description.into(),
1227 schema_name: Some(name),
1228 });
1229 OperationBuilder {
1230 spec: self.spec,
1231 method_router: self.method_router,
1232 _has_handler: self._has_handler,
1233 _has_response: PhantomData::<Present>,
1234 _state: self._state,
1235 _auth_state: self._auth_state,
1236 _license_state: self._license_state,
1237 }
1238 }
1239}
1240
1241impl<H, S, A, L> OperationBuilder<H, Present, S, A, L>
1245where
1246 H: HandlerSlot<S>,
1247 A: AuthState,
1248 L: LicenseState,
1249{
1250 pub fn json_response(
1252 mut self,
1253 status: http::StatusCode,
1254 description: impl Into<String>,
1255 ) -> Self {
1256 self.spec.responses.push(ResponseSpec {
1257 status: status.as_u16(),
1258 content_type: "application/json",
1259 description: description.into(),
1260 schema_name: None,
1261 });
1262 self
1263 }
1264
1265 pub fn json_response_with_schema<T>(
1267 mut self,
1268 registry: &dyn OpenApiRegistry,
1269 status: http::StatusCode,
1270 description: impl Into<String>,
1271 ) -> Self
1272 where
1273 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
1274 {
1275 let name = ensure_schema::<T>(registry);
1276 self.spec.responses.push(ResponseSpec {
1277 status: status.as_u16(),
1278 content_type: "application/json",
1279 description: description.into(),
1280 schema_name: Some(name),
1281 });
1282 self
1283 }
1284
1285 pub fn text_response(
1298 mut self,
1299 status: http::StatusCode,
1300 description: impl Into<String>,
1301 content_type: &'static str,
1302 ) -> Self {
1303 self.spec.responses.push(ResponseSpec {
1304 status: status.as_u16(),
1305 content_type,
1306 description: description.into(),
1307 schema_name: None,
1308 });
1309 self
1310 }
1311
1312 pub fn html_response(
1314 mut self,
1315 status: http::StatusCode,
1316 description: impl Into<String>,
1317 ) -> Self {
1318 self.spec.responses.push(ResponseSpec {
1319 status: status.as_u16(),
1320 content_type: "text/html",
1321 description: description.into(),
1322 schema_name: None,
1323 });
1324 self
1325 }
1326
1327 pub fn problem_response(
1329 mut self,
1330 registry: &dyn OpenApiRegistry,
1331 status: http::StatusCode,
1332 description: impl Into<String>,
1333 ) -> Self {
1334 let problem_name = ensure_schema::<crate::api::problem::Problem>(registry);
1335 self.spec.responses.push(ResponseSpec {
1336 status: status.as_u16(),
1337 content_type: problem::APPLICATION_PROBLEM_JSON,
1338 description: description.into(),
1339 schema_name: Some(problem_name),
1340 });
1341 self
1342 }
1343
1344 pub fn sse_json<T>(
1346 mut self,
1347 openapi: &dyn OpenApiRegistry,
1348 description: impl Into<String>,
1349 ) -> Self
1350 where
1351 T: utoipa::ToSchema + utoipa::PartialSchema + api_dto::ResponseApiDto + 'static,
1352 {
1353 let name = ensure_schema::<T>(openapi);
1354 self.spec.responses.push(ResponseSpec {
1355 status: http::StatusCode::OK.as_u16(),
1356 content_type: "text/event-stream",
1357 description: description.into(),
1358 schema_name: Some(name),
1359 });
1360 self
1361 }
1362
1363 pub fn standard_errors(mut self, registry: &dyn OpenApiRegistry) -> Self {
1401 use http::StatusCode;
1402 let problem_name = ensure_schema::<crate::api::problem::Problem>(registry);
1403
1404 let standard_errors = [
1405 (StatusCode::BAD_REQUEST, "Bad Request"),
1406 (StatusCode::UNAUTHORIZED, "Unauthorized"),
1407 (StatusCode::FORBIDDEN, "Forbidden"),
1408 (StatusCode::NOT_FOUND, "Not Found"),
1409 (StatusCode::CONFLICT, "Conflict"),
1410 (StatusCode::UNPROCESSABLE_ENTITY, "Unprocessable Entity"),
1411 (StatusCode::TOO_MANY_REQUESTS, "Too Many Requests"),
1412 (StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error"),
1413 ];
1414
1415 for (status, description) in standard_errors {
1416 self.spec.responses.push(ResponseSpec {
1417 status: status.as_u16(),
1418 content_type: problem::APPLICATION_PROBLEM_JSON,
1419 description: description.to_owned(),
1420 schema_name: Some(problem_name.clone()),
1421 });
1422 }
1423
1424 self
1425 }
1426
1427 pub fn with_422_validation_error(mut self, registry: &dyn OpenApiRegistry) -> Self {
1464 let validation_error_name =
1465 ensure_schema::<crate::api::problem::ValidationErrorResponse>(registry);
1466
1467 self.spec.responses.push(ResponseSpec {
1468 status: http::StatusCode::UNPROCESSABLE_ENTITY.as_u16(),
1469 content_type: problem::APPLICATION_PROBLEM_JSON,
1470 description: "Validation Error".to_owned(),
1471 schema_name: Some(validation_error_name),
1472 });
1473
1474 self
1475 }
1476
1477 pub fn error_400(self, registry: &dyn OpenApiRegistry) -> Self {
1481 self.problem_response(registry, http::StatusCode::BAD_REQUEST, "Bad Request")
1482 }
1483
1484 pub fn error_401(self, registry: &dyn OpenApiRegistry) -> Self {
1488 self.problem_response(registry, http::StatusCode::UNAUTHORIZED, "Unauthorized")
1489 }
1490
1491 pub fn error_403(self, registry: &dyn OpenApiRegistry) -> Self {
1495 self.problem_response(registry, http::StatusCode::FORBIDDEN, "Forbidden")
1496 }
1497
1498 pub fn error_404(self, registry: &dyn OpenApiRegistry) -> Self {
1502 self.problem_response(registry, http::StatusCode::NOT_FOUND, "Not Found")
1503 }
1504
1505 pub fn error_409(self, registry: &dyn OpenApiRegistry) -> Self {
1509 self.problem_response(registry, http::StatusCode::CONFLICT, "Conflict")
1510 }
1511
1512 pub fn error_415(self, registry: &dyn OpenApiRegistry) -> Self {
1516 self.problem_response(
1517 registry,
1518 http::StatusCode::UNSUPPORTED_MEDIA_TYPE,
1519 "Unsupported Media Type",
1520 )
1521 }
1522
1523 pub fn error_422(self, registry: &dyn OpenApiRegistry) -> Self {
1527 self.problem_response(
1528 registry,
1529 http::StatusCode::UNPROCESSABLE_ENTITY,
1530 "Unprocessable Entity",
1531 )
1532 }
1533
1534 pub fn error_429(self, registry: &dyn OpenApiRegistry) -> Self {
1538 self.problem_response(
1539 registry,
1540 http::StatusCode::TOO_MANY_REQUESTS,
1541 "Too Many Requests",
1542 )
1543 }
1544
1545 pub fn error_500(self, registry: &dyn OpenApiRegistry) -> Self {
1549 self.problem_response(
1550 registry,
1551 http::StatusCode::INTERNAL_SERVER_ERROR,
1552 "Internal Server Error",
1553 )
1554 }
1555}
1556
1557impl<S> OperationBuilder<Present, Present, S, AuthSet, LicenseSet>
1561where
1562 S: Clone + Send + Sync + 'static,
1563{
1564 pub fn register(self, router: Router<S>, openapi: &dyn OpenApiRegistry) -> Router<S> {
1573 openapi.register_operation(&self.spec);
1576
1577 router.route(&self.spec.path, self.method_router)
1579 }
1580}
1581
1582#[cfg(test)]
1586#[cfg_attr(coverage_nightly, coverage(off))]
1587mod tests {
1588 use super::*;
1589 use axum::Json;
1590
1591 struct MockRegistry {
1593 operations: std::sync::Mutex<Vec<OperationSpec>>,
1594 schemas: std::sync::Mutex<Vec<String>>,
1595 }
1596
1597 impl MockRegistry {
1598 fn new() -> Self {
1599 Self {
1600 operations: std::sync::Mutex::new(Vec::new()),
1601 schemas: std::sync::Mutex::new(Vec::new()),
1602 }
1603 }
1604 }
1605
1606 enum TestLicenseFeatures {
1607 FeatureA,
1608 FeatureB,
1609 }
1610 impl AsRef<str> for TestLicenseFeatures {
1611 fn as_ref(&self) -> &str {
1612 match self {
1613 TestLicenseFeatures::FeatureA => "feature_a",
1614 TestLicenseFeatures::FeatureB => "feature_b",
1615 }
1616 }
1617 }
1618 impl LicenseFeature for TestLicenseFeatures {}
1619
1620 impl OpenApiRegistry for MockRegistry {
1621 fn register_operation(&self, spec: &OperationSpec) {
1622 if let Ok(mut ops) = self.operations.lock() {
1623 ops.push(spec.clone());
1624 }
1625 }
1626
1627 fn ensure_schema_raw(
1628 &self,
1629 name: &str,
1630 _schemas: Vec<(
1631 String,
1632 utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>,
1633 )>,
1634 ) -> String {
1635 let name = name.to_owned();
1636 if let Ok(mut s) = self.schemas.lock() {
1637 s.push(name.clone());
1638 }
1639 name
1640 }
1641
1642 fn as_any(&self) -> &dyn std::any::Any {
1643 self
1644 }
1645 }
1646
1647 async fn test_handler() -> Json<serde_json::Value> {
1648 Json(serde_json::json!({"status": "ok"}))
1649 }
1650
1651 #[modkit_macros::api_dto(request)]
1652 struct SampleDtoRequest;
1653
1654 #[modkit_macros::api_dto(response)]
1655 struct SampleDtoResponse;
1656
1657 #[test]
1658 fn builder_descriptive_methods() {
1659 let builder = OperationBuilder::<Missing, Missing, (), AuthNotSet>::get("/tests/v1/test")
1660 .operation_id("test.get")
1661 .summary("Test endpoint")
1662 .description("A test endpoint for validation")
1663 .tag("test")
1664 .path_param("id", "Test ID");
1665
1666 assert_eq!(builder.spec.method, Method::GET);
1667 assert_eq!(builder.spec.path, "/tests/v1/test");
1668 assert_eq!(builder.spec.operation_id, Some("test.get".to_owned()));
1669 assert_eq!(builder.spec.summary, Some("Test endpoint".to_owned()));
1670 assert_eq!(
1671 builder.spec.description,
1672 Some("A test endpoint for validation".to_owned())
1673 );
1674 assert_eq!(builder.spec.tags, vec!["test"]);
1675 assert_eq!(builder.spec.params.len(), 1);
1676 }
1677
1678 #[tokio::test]
1679 async fn builder_with_request_response_and_handler() {
1680 let registry = MockRegistry::new();
1681 let router = Router::new();
1682
1683 let _router = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
1684 .summary("Test endpoint")
1685 .json_request::<SampleDtoRequest>(®istry, "optional body") .public()
1687 .handler(test_handler)
1688 .json_response_with_schema::<SampleDtoResponse>(
1689 ®istry,
1690 http::StatusCode::OK,
1691 "Success response",
1692 ) .register(router, ®istry);
1694
1695 let ops = registry.operations.lock().unwrap();
1697 assert_eq!(ops.len(), 1);
1698 let op = &ops[0];
1699 assert_eq!(op.method, Method::POST);
1700 assert_eq!(op.path, "/tests/v1/test");
1701 assert!(op.request_body.is_some());
1702 assert!(op.request_body.as_ref().unwrap().required);
1703 assert_eq!(op.responses.len(), 1);
1704 assert_eq!(op.responses[0].status, 200);
1705
1706 let schemas = registry.schemas.lock().unwrap();
1708 assert!(!schemas.is_empty());
1709 }
1710
1711 #[test]
1712 fn convenience_constructors() {
1713 let get_builder =
1714 OperationBuilder::<Missing, Missing, (), AuthNotSet>::get("/tests/v1/get");
1715 assert_eq!(get_builder.spec.method, Method::GET);
1716 assert_eq!(get_builder.spec.path, "/tests/v1/get");
1717
1718 let post_builder =
1719 OperationBuilder::<Missing, Missing, (), AuthNotSet>::post("/tests/v1/post");
1720 assert_eq!(post_builder.spec.method, Method::POST);
1721 assert_eq!(post_builder.spec.path, "/tests/v1/post");
1722
1723 let put_builder =
1724 OperationBuilder::<Missing, Missing, (), AuthNotSet>::put("/tests/v1/put");
1725 assert_eq!(put_builder.spec.method, Method::PUT);
1726 assert_eq!(put_builder.spec.path, "/tests/v1/put");
1727
1728 let delete_builder =
1729 OperationBuilder::<Missing, Missing, (), AuthNotSet>::delete("/tests/v1/delete");
1730 assert_eq!(delete_builder.spec.method, Method::DELETE);
1731 assert_eq!(delete_builder.spec.path, "/tests/v1/delete");
1732
1733 let patch_builder =
1734 OperationBuilder::<Missing, Missing, (), AuthNotSet>::patch("/tests/v1/patch");
1735 assert_eq!(patch_builder.spec.method, Method::PATCH);
1736 assert_eq!(patch_builder.spec.path, "/tests/v1/patch");
1737 }
1738
1739 #[test]
1740 fn normalize_to_axum_path_should_normalize() {
1741 assert_eq!(
1743 normalize_to_axum_path("/tests/v1/users/{id}"),
1744 "/tests/v1/users/{id}"
1745 );
1746 assert_eq!(
1747 normalize_to_axum_path("/tests/v1/projects/{project_id}/items/{item_id}"),
1748 "/tests/v1/projects/{project_id}/items/{item_id}"
1749 );
1750 assert_eq!(
1751 normalize_to_axum_path("/tests/v1/simple"),
1752 "/tests/v1/simple"
1753 );
1754 assert_eq!(
1755 normalize_to_axum_path("/tests/v1/users/{id}/edit"),
1756 "/tests/v1/users/{id}/edit"
1757 );
1758 }
1759
1760 #[test]
1761 fn axum_to_openapi_path_should_convert() {
1762 assert_eq!(
1764 axum_to_openapi_path("/tests/v1/users/{id}"),
1765 "/tests/v1/users/{id}"
1766 );
1767 assert_eq!(
1768 axum_to_openapi_path("/tests/v1/projects/{project_id}/items/{item_id}"),
1769 "/tests/v1/projects/{project_id}/items/{item_id}"
1770 );
1771 assert_eq!(axum_to_openapi_path("/tests/v1/simple"), "/tests/v1/simple");
1772 assert_eq!(
1774 axum_to_openapi_path("/tests/v1/static/{*path}"),
1775 "/tests/v1/static/{path}"
1776 );
1777 assert_eq!(
1778 axum_to_openapi_path("/tests/v1/files/{*filepath}"),
1779 "/tests/v1/files/{filepath}"
1780 );
1781 }
1782
1783 #[test]
1784 fn path_normalization_in_constructors() {
1785 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/users/{id}");
1787 assert_eq!(builder.spec.path, "/tests/v1/users/{id}");
1788
1789 let builder = OperationBuilder::<Missing, Missing, ()>::post(
1790 "/tests/v1/projects/{project_id}/items/{item_id}",
1791 );
1792 assert_eq!(
1793 builder.spec.path,
1794 "/tests/v1/projects/{project_id}/items/{item_id}"
1795 );
1796
1797 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/simple");
1799 assert_eq!(builder.spec.path, "/tests/v1/simple");
1800 }
1801
1802 #[test]
1803 fn standard_errors() {
1804 let registry = MockRegistry::new();
1805 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1806 .public()
1807 .handler(test_handler)
1808 .json_response(http::StatusCode::OK, "Success")
1809 .standard_errors(®istry);
1810
1811 assert_eq!(builder.spec.responses.len(), 9);
1813
1814 let statuses: Vec<u16> = builder.spec.responses.iter().map(|r| r.status).collect();
1816 assert!(statuses.contains(&200)); assert!(statuses.contains(&400));
1818 assert!(statuses.contains(&401));
1819 assert!(statuses.contains(&403));
1820 assert!(statuses.contains(&404));
1821 assert!(statuses.contains(&409));
1822 assert!(statuses.contains(&422));
1823 assert!(statuses.contains(&429));
1824 assert!(statuses.contains(&500));
1825
1826 let error_responses: Vec<_> = builder
1828 .spec
1829 .responses
1830 .iter()
1831 .filter(|r| r.status >= 400)
1832 .collect();
1833
1834 for resp in error_responses {
1835 assert_eq!(
1836 resp.content_type,
1837 crate::api::problem::APPLICATION_PROBLEM_JSON
1838 );
1839 assert!(resp.schema_name.is_some());
1840 }
1841 }
1842
1843 enum TestResource {
1844 Users,
1845 }
1846
1847 enum TestAction {
1848 Read,
1849 }
1850
1851 impl AsRef<str> for TestResource {
1852 fn as_ref(&self) -> &'static str {
1853 match self {
1854 TestResource::Users => "users",
1855 }
1856 }
1857 }
1858
1859 impl AuthReqResource for TestResource {}
1860
1861 impl AsRef<str> for TestAction {
1862 fn as_ref(&self) -> &'static str {
1863 match self {
1864 TestAction::Read => "read",
1865 }
1866 }
1867 }
1868
1869 impl AuthReqAction for TestAction {}
1870
1871 #[test]
1872 fn require_auth() {
1873 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1874 .require_auth(&TestResource::Users, &TestAction::Read)
1875 .handler(test_handler)
1876 .json_response(http::StatusCode::OK, "Success");
1877
1878 let sec_requirement = builder
1879 .spec
1880 .sec_requirement
1881 .expect("Should have security requirement");
1882 assert_eq!(sec_requirement.resource, "users");
1883 assert_eq!(sec_requirement.action, "read");
1884 }
1885
1886 #[test]
1887 fn require_license_features_none() {
1888 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1889 .require_auth(&TestResource::Users, &TestAction::Read)
1890 .require_license_features::<TestLicenseFeatures>([])
1891 .handler(|| async {})
1892 .json_response(http::StatusCode::OK, "OK");
1893
1894 assert!(builder.spec.license_requirement.is_none());
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 .require_auth(&TestResource::Users, &TestAction::Read)
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 .require_auth(&TestResource::Users, &TestAction::Read)
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}