1use crate::api::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 + '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 + '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 + '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 + '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 + '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 + '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 #[test]
1652 fn builder_descriptive_methods() {
1653 let builder = OperationBuilder::<Missing, Missing, (), AuthNotSet>::get("/tests/v1/test")
1654 .operation_id("test.get")
1655 .summary("Test endpoint")
1656 .description("A test endpoint for validation")
1657 .tag("test")
1658 .path_param("id", "Test ID");
1659
1660 assert_eq!(builder.spec.method, Method::GET);
1661 assert_eq!(builder.spec.path, "/tests/v1/test");
1662 assert_eq!(builder.spec.operation_id, Some("test.get".to_owned()));
1663 assert_eq!(builder.spec.summary, Some("Test endpoint".to_owned()));
1664 assert_eq!(
1665 builder.spec.description,
1666 Some("A test endpoint for validation".to_owned())
1667 );
1668 assert_eq!(builder.spec.tags, vec!["test"]);
1669 assert_eq!(builder.spec.params.len(), 1);
1670 }
1671
1672 #[tokio::test]
1673 async fn builder_with_request_response_and_handler() {
1674 let registry = MockRegistry::new();
1675 let router = Router::new();
1676
1677 let _router = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
1678 .summary("Test endpoint")
1679 .json_request::<serde_json::Value>(®istry, "optional body") .public()
1681 .handler(test_handler)
1682 .json_response_with_schema::<serde_json::Value>(
1683 ®istry,
1684 http::StatusCode::OK,
1685 "Success response",
1686 ) .register(router, ®istry);
1688
1689 let ops = registry.operations.lock().unwrap();
1691 assert_eq!(ops.len(), 1);
1692 let op = &ops[0];
1693 assert_eq!(op.method, Method::POST);
1694 assert_eq!(op.path, "/tests/v1/test");
1695 assert!(op.request_body.is_some());
1696 assert!(op.request_body.as_ref().unwrap().required);
1697 assert_eq!(op.responses.len(), 1);
1698 assert_eq!(op.responses[0].status, 200);
1699
1700 let schemas = registry.schemas.lock().unwrap();
1702 assert!(!schemas.is_empty());
1703 }
1704
1705 #[test]
1706 fn convenience_constructors() {
1707 let get_builder =
1708 OperationBuilder::<Missing, Missing, (), AuthNotSet>::get("/tests/v1/get");
1709 assert_eq!(get_builder.spec.method, Method::GET);
1710 assert_eq!(get_builder.spec.path, "/tests/v1/get");
1711
1712 let post_builder =
1713 OperationBuilder::<Missing, Missing, (), AuthNotSet>::post("/tests/v1/post");
1714 assert_eq!(post_builder.spec.method, Method::POST);
1715 assert_eq!(post_builder.spec.path, "/tests/v1/post");
1716
1717 let put_builder =
1718 OperationBuilder::<Missing, Missing, (), AuthNotSet>::put("/tests/v1/put");
1719 assert_eq!(put_builder.spec.method, Method::PUT);
1720 assert_eq!(put_builder.spec.path, "/tests/v1/put");
1721
1722 let delete_builder =
1723 OperationBuilder::<Missing, Missing, (), AuthNotSet>::delete("/tests/v1/delete");
1724 assert_eq!(delete_builder.spec.method, Method::DELETE);
1725 assert_eq!(delete_builder.spec.path, "/tests/v1/delete");
1726
1727 let patch_builder =
1728 OperationBuilder::<Missing, Missing, (), AuthNotSet>::patch("/tests/v1/patch");
1729 assert_eq!(patch_builder.spec.method, Method::PATCH);
1730 assert_eq!(patch_builder.spec.path, "/tests/v1/patch");
1731 }
1732
1733 #[test]
1734 fn normalize_to_axum_path_should_normalize() {
1735 assert_eq!(
1737 normalize_to_axum_path("/tests/v1/users/{id}"),
1738 "/tests/v1/users/{id}"
1739 );
1740 assert_eq!(
1741 normalize_to_axum_path("/tests/v1/projects/{project_id}/items/{item_id}"),
1742 "/tests/v1/projects/{project_id}/items/{item_id}"
1743 );
1744 assert_eq!(
1745 normalize_to_axum_path("/tests/v1/simple"),
1746 "/tests/v1/simple"
1747 );
1748 assert_eq!(
1749 normalize_to_axum_path("/tests/v1/users/{id}/edit"),
1750 "/tests/v1/users/{id}/edit"
1751 );
1752 }
1753
1754 #[test]
1755 fn axum_to_openapi_path_should_convert() {
1756 assert_eq!(
1758 axum_to_openapi_path("/tests/v1/users/{id}"),
1759 "/tests/v1/users/{id}"
1760 );
1761 assert_eq!(
1762 axum_to_openapi_path("/tests/v1/projects/{project_id}/items/{item_id}"),
1763 "/tests/v1/projects/{project_id}/items/{item_id}"
1764 );
1765 assert_eq!(axum_to_openapi_path("/tests/v1/simple"), "/tests/v1/simple");
1766 assert_eq!(
1768 axum_to_openapi_path("/tests/v1/static/{*path}"),
1769 "/tests/v1/static/{path}"
1770 );
1771 assert_eq!(
1772 axum_to_openapi_path("/tests/v1/files/{*filepath}"),
1773 "/tests/v1/files/{filepath}"
1774 );
1775 }
1776
1777 #[test]
1778 fn path_normalization_in_constructors() {
1779 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/users/{id}");
1781 assert_eq!(builder.spec.path, "/tests/v1/users/{id}");
1782
1783 let builder = OperationBuilder::<Missing, Missing, ()>::post(
1784 "/tests/v1/projects/{project_id}/items/{item_id}",
1785 );
1786 assert_eq!(
1787 builder.spec.path,
1788 "/tests/v1/projects/{project_id}/items/{item_id}"
1789 );
1790
1791 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/simple");
1793 assert_eq!(builder.spec.path, "/tests/v1/simple");
1794 }
1795
1796 #[test]
1797 fn standard_errors() {
1798 let registry = MockRegistry::new();
1799 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1800 .public()
1801 .handler(test_handler)
1802 .json_response(http::StatusCode::OK, "Success")
1803 .standard_errors(®istry);
1804
1805 assert_eq!(builder.spec.responses.len(), 9);
1807
1808 let statuses: Vec<u16> = builder.spec.responses.iter().map(|r| r.status).collect();
1810 assert!(statuses.contains(&200)); assert!(statuses.contains(&400));
1812 assert!(statuses.contains(&401));
1813 assert!(statuses.contains(&403));
1814 assert!(statuses.contains(&404));
1815 assert!(statuses.contains(&409));
1816 assert!(statuses.contains(&422));
1817 assert!(statuses.contains(&429));
1818 assert!(statuses.contains(&500));
1819
1820 let error_responses: Vec<_> = builder
1822 .spec
1823 .responses
1824 .iter()
1825 .filter(|r| r.status >= 400)
1826 .collect();
1827
1828 for resp in error_responses {
1829 assert_eq!(
1830 resp.content_type,
1831 crate::api::problem::APPLICATION_PROBLEM_JSON
1832 );
1833 assert!(resp.schema_name.is_some());
1834 }
1835 }
1836
1837 enum TestResource {
1838 Users,
1839 }
1840
1841 enum TestAction {
1842 Read,
1843 }
1844
1845 impl AsRef<str> for TestResource {
1846 fn as_ref(&self) -> &'static str {
1847 match self {
1848 TestResource::Users => "users",
1849 }
1850 }
1851 }
1852
1853 impl AuthReqResource for TestResource {}
1854
1855 impl AsRef<str> for TestAction {
1856 fn as_ref(&self) -> &'static str {
1857 match self {
1858 TestAction::Read => "read",
1859 }
1860 }
1861 }
1862
1863 impl AuthReqAction for TestAction {}
1864
1865 #[test]
1866 fn require_auth() {
1867 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1868 .require_auth(&TestResource::Users, &TestAction::Read)
1869 .handler(test_handler)
1870 .json_response(http::StatusCode::OK, "Success");
1871
1872 let sec_requirement = builder
1873 .spec
1874 .sec_requirement
1875 .expect("Should have security requirement");
1876 assert_eq!(sec_requirement.resource, "users");
1877 assert_eq!(sec_requirement.action, "read");
1878 }
1879
1880 #[test]
1881 fn require_license_features_none() {
1882 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1883 .require_auth(&TestResource::Users, &TestAction::Read)
1884 .require_license_features::<TestLicenseFeatures>([])
1885 .handler(|| async {})
1886 .json_response(http::StatusCode::OK, "OK");
1887
1888 assert!(builder.spec.license_requirement.is_none());
1889 }
1890
1891 #[test]
1892 fn require_license_features_one() {
1893 let feature = TestLicenseFeatures::FeatureA;
1894
1895 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1896 .require_auth(&TestResource::Users, &TestAction::Read)
1897 .require_license_features([&feature])
1898 .handler(|| async {})
1899 .json_response(http::StatusCode::OK, "OK");
1900
1901 let license_req = builder
1902 .spec
1903 .license_requirement
1904 .as_ref()
1905 .expect("Should have license requirement");
1906 assert_eq!(license_req.license_names, vec!["feature_a".to_owned()]);
1907 }
1908
1909 #[test]
1910 fn require_license_features_many() {
1911 let feature_a = TestLicenseFeatures::FeatureA;
1912 let feature_b = TestLicenseFeatures::FeatureB;
1913
1914 let builder = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1915 .require_auth(&TestResource::Users, &TestAction::Read)
1916 .require_license_features([&feature_a, &feature_b])
1917 .handler(|| async {})
1918 .json_response(http::StatusCode::OK, "OK");
1919
1920 let license_req = builder
1921 .spec
1922 .license_requirement
1923 .as_ref()
1924 .expect("Should have license requirement");
1925 assert_eq!(
1926 license_req.license_names,
1927 vec!["feature_a".to_owned(), "feature_b".to_owned()]
1928 );
1929 }
1930
1931 #[tokio::test]
1932 async fn public_does_not_require_license_features_and_can_register() {
1933 let registry = MockRegistry::new();
1934 let router = Router::new();
1935
1936 let _router = OperationBuilder::<Missing, Missing, ()>::get("/tests/v1/test")
1937 .public()
1938 .handler(test_handler)
1939 .json_response(http::StatusCode::OK, "Success")
1940 .register(router, ®istry);
1941
1942 let ops = registry.operations.lock().unwrap();
1943 assert_eq!(ops.len(), 1);
1944 assert!(ops[0].license_requirement.is_none());
1945 }
1946
1947 #[test]
1948 fn with_422_validation_error() {
1949 let registry = MockRegistry::new();
1950 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
1951 .public()
1952 .handler(test_handler)
1953 .json_response(http::StatusCode::CREATED, "Created")
1954 .with_422_validation_error(®istry);
1955
1956 assert_eq!(builder.spec.responses.len(), 2);
1958
1959 let validation_response = builder
1960 .spec
1961 .responses
1962 .iter()
1963 .find(|r| r.status == 422)
1964 .expect("Should have 422 response");
1965
1966 assert_eq!(validation_response.description, "Validation Error");
1967 assert_eq!(
1968 validation_response.content_type,
1969 crate::api::problem::APPLICATION_PROBLEM_JSON
1970 );
1971 assert!(validation_response.schema_name.is_some());
1972 }
1973
1974 #[test]
1975 fn allow_content_types_with_existing_request_body() {
1976 let registry = MockRegistry::new();
1977 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
1978 .json_request::<serde_json::Value>(®istry, "Test request")
1979 .allow_content_types(&["application/json", "application/xml"])
1980 .public()
1981 .handler(test_handler)
1982 .json_response(http::StatusCode::OK, "Success");
1983
1984 assert!(builder.spec.request_body.is_some());
1986 assert!(builder.spec.allowed_request_content_types.is_some());
1987 let allowed = builder.spec.allowed_request_content_types.as_ref().unwrap();
1988 assert_eq!(allowed.len(), 2);
1989 assert!(allowed.contains(&"application/json"));
1990 assert!(allowed.contains(&"application/xml"));
1991 }
1992
1993 #[test]
1994 fn allow_content_types_without_existing_request_body() {
1995 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
1996 .allow_content_types(&["multipart/form-data"])
1997 .public()
1998 .handler(test_handler)
1999 .json_response(http::StatusCode::OK, "Success");
2000
2001 assert!(builder.spec.request_body.is_none());
2003 assert!(builder.spec.allowed_request_content_types.is_some());
2004 let allowed = builder.spec.allowed_request_content_types.as_ref().unwrap();
2005 assert_eq!(allowed.len(), 1);
2006 assert!(allowed.contains(&"multipart/form-data"));
2007 }
2008
2009 #[test]
2010 fn allow_content_types_can_be_chained() {
2011 let registry = MockRegistry::new();
2012 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
2013 .operation_id("test.post")
2014 .summary("Test endpoint")
2015 .json_request::<serde_json::Value>(®istry, "Test request")
2016 .allow_content_types(&["application/json"])
2017 .public()
2018 .handler(test_handler)
2019 .json_response(http::StatusCode::OK, "Success")
2020 .problem_response(
2021 ®istry,
2022 http::StatusCode::UNSUPPORTED_MEDIA_TYPE,
2023 "Unsupported Media Type",
2024 );
2025
2026 assert_eq!(builder.spec.operation_id, Some("test.post".to_owned()));
2027 assert!(builder.spec.request_body.is_some());
2028 assert!(builder.spec.allowed_request_content_types.is_some());
2029 assert_eq!(builder.spec.responses.len(), 2);
2030 }
2031
2032 #[test]
2033 fn multipart_file_request() {
2034 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/upload")
2035 .operation_id("test.upload")
2036 .summary("Upload file")
2037 .multipart_file_request("file", Some("Upload a file"))
2038 .public()
2039 .handler(test_handler)
2040 .json_response(http::StatusCode::OK, "Success");
2041
2042 assert!(builder.spec.request_body.is_some());
2044 let rb = builder.spec.request_body.as_ref().unwrap();
2045 assert_eq!(rb.content_type, "multipart/form-data");
2046 assert!(rb.description.is_some());
2047 assert!(rb.description.as_ref().unwrap().contains("file"));
2048 assert!(rb.required);
2049
2050 assert_eq!(
2052 rb.schema,
2053 RequestBodySchema::MultipartFile {
2054 field_name: "file".to_owned()
2055 }
2056 );
2057
2058 assert!(builder.spec.allowed_request_content_types.is_some());
2060 let allowed = builder.spec.allowed_request_content_types.as_ref().unwrap();
2061 assert_eq!(allowed.len(), 1);
2062 assert!(allowed.contains(&"multipart/form-data"));
2063 }
2064
2065 #[test]
2066 fn multipart_file_request_without_description() {
2067 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/upload")
2068 .multipart_file_request("file", None)
2069 .public()
2070 .handler(test_handler)
2071 .json_response(http::StatusCode::OK, "Success");
2072
2073 assert!(builder.spec.request_body.is_some());
2074 let rb = builder.spec.request_body.as_ref().unwrap();
2075 assert_eq!(rb.content_type, "multipart/form-data");
2076 assert!(rb.description.is_none());
2077 assert_eq!(
2078 rb.schema,
2079 RequestBodySchema::MultipartFile {
2080 field_name: "file".to_owned()
2081 }
2082 );
2083 }
2084
2085 #[test]
2086 fn octet_stream_request() {
2087 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/upload")
2088 .operation_id("test.upload")
2089 .summary("Upload raw file")
2090 .octet_stream_request(Some("Raw file bytes"))
2091 .public()
2092 .handler(test_handler)
2093 .json_response(http::StatusCode::OK, "Success");
2094
2095 assert!(builder.spec.request_body.is_some());
2097 let rb = builder.spec.request_body.as_ref().unwrap();
2098 assert_eq!(rb.content_type, "application/octet-stream");
2099 assert_eq!(rb.description, Some("Raw file bytes".to_owned()));
2100 assert!(rb.required);
2101
2102 assert_eq!(rb.schema, RequestBodySchema::Binary);
2104
2105 assert!(builder.spec.allowed_request_content_types.is_some());
2107 let allowed = builder.spec.allowed_request_content_types.as_ref().unwrap();
2108 assert_eq!(allowed.len(), 1);
2109 assert!(allowed.contains(&"application/octet-stream"));
2110 }
2111
2112 #[test]
2113 fn octet_stream_request_without_description() {
2114 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/upload")
2115 .octet_stream_request(None)
2116 .public()
2117 .handler(test_handler)
2118 .json_response(http::StatusCode::OK, "Success");
2119
2120 assert!(builder.spec.request_body.is_some());
2121 let rb = builder.spec.request_body.as_ref().unwrap();
2122 assert_eq!(rb.content_type, "application/octet-stream");
2123 assert!(rb.description.is_none());
2124 assert_eq!(rb.schema, RequestBodySchema::Binary);
2125 }
2126
2127 #[test]
2128 fn json_request_uses_ref_schema() {
2129 let registry = MockRegistry::new();
2130 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
2131 .json_request::<serde_json::Value>(®istry, "Test request body")
2132 .public()
2133 .handler(test_handler)
2134 .json_response(http::StatusCode::OK, "Success");
2135
2136 assert!(builder.spec.request_body.is_some());
2137 let rb = builder.spec.request_body.as_ref().unwrap();
2138 assert_eq!(rb.content_type, "application/json");
2139
2140 match &rb.schema {
2142 RequestBodySchema::Ref { schema_name } => {
2143 assert!(!schema_name.is_empty());
2144 }
2145 _ => panic!("Expected RequestBodySchema::Ref for JSON request"),
2146 }
2147 }
2148
2149 #[test]
2150 fn response_content_types_must_not_contain_parameters() {
2151 let registry = MockRegistry::new();
2154 let builder = OperationBuilder::<Missing, Missing, ()>::post("/tests/v1/test")
2155 .operation_id("test.content_type_purity")
2156 .summary("Test response content types")
2157 .json_request::<serde_json::Value>(®istry, "Test")
2158 .public()
2159 .handler(test_handler)
2160 .text_response(http::StatusCode::OK, "Text", "text/plain")
2161 .text_response(http::StatusCode::OK, "Markdown", "text/markdown")
2162 .html_response(http::StatusCode::OK, "HTML")
2163 .json_response(http::StatusCode::OK, "JSON")
2164 .problem_response(®istry, http::StatusCode::BAD_REQUEST, "Error");
2165
2166 for response in &builder.spec.responses {
2168 assert!(
2169 !response.content_type.contains(';'),
2170 "Response content_type '{}' must not contain parameters. \
2171 Use pure media type without charset or other parameters. \
2172 OpenAPI media type keys cannot include parameters.",
2173 response.content_type
2174 );
2175 }
2176 }
2177}