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