1#![deny(missing_docs)]
29
30use std::collections::BTreeMap;
31use std::error::Error as StdError;
32use std::fmt;
33use std::fs::{self, File, OpenOptions};
34use std::io::{self, Write};
35use std::path::{Path, PathBuf};
36use std::sync::atomic::{AtomicU64, Ordering};
37
38use serde::{Deserialize, Serialize};
39use serde_json::Value;
40use utoipa::ToSchema;
41use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
42use utoipa::openapi::{Components, OpenApi, Server};
43
44mod error_responses;
45mod naming;
46mod precondition;
47mod response_descriptions;
48
49pub use error_responses::{
50 ErrorResponseRules, OperationCondition, REQUEST_ID_HEADER, RETRY_AFTER_HEADER,
51 ResponseSelector, WWW_AUTHENTICATE_HEADER, request_id_header, retry_after_header,
52 www_authenticate_header,
53};
54pub use naming::{NameKind, NamingViolation, find_naming_violations, is_camel_case};
55pub use precondition::{
56 ETAG_HEADER, IF_MATCH_HEADER, INVALID_IF_MATCH_CODE, IfMatchRequirement,
57 PRECONDITION_FAILED_CODE, PRECONDITION_REQUIRED_CODE, document_etag, document_if_match,
58 etag_header, if_match_parameter,
59};
60pub use response_descriptions::fill_missing_response_descriptions;
61
62pub const BEARER_AUTH_SCHEME: &str = "bearerAuth";
64
65#[derive(Clone)]
73pub struct OpenApiMetadata {
74 title: String,
75 version: String,
76 description: String,
77 servers: Vec<Server>,
78 bearer_auth: bool,
79}
80
81impl OpenApiMetadata {
82 pub fn new<T, V, D>(title: T, version: V, description: D) -> Self
84 where
85 T: Into<String>,
86 V: Into<String>,
87 D: Into<String>,
88 {
89 Self {
90 title: title.into(),
91 version: version.into(),
92 description: description.into(),
93 servers: vec![Server::new("/")],
94 bearer_auth: false,
95 }
96 }
97
98 #[must_use]
103 pub fn servers<I>(mut self, servers: I) -> Self
104 where
105 I: IntoIterator<Item = Server>,
106 {
107 self.servers = servers.into_iter().collect();
108 self
109 }
110
111 #[must_use]
116 pub const fn bearer_auth(mut self) -> Self {
117 self.bearer_auth = true;
118 self
119 }
120
121 pub fn apply_to(&self, openapi: &mut OpenApi) {
123 fill_missing_response_descriptions(openapi);
124 openapi.info.title.clone_from(&self.title);
125 openapi.info.version.clone_from(&self.version);
126 openapi.info.description = Some(self.description.clone());
127 openapi.servers = (!self.servers.is_empty()).then(|| self.servers.clone());
128
129 if self.bearer_auth {
130 let components = openapi.components.get_or_insert_with(Components::new);
131 let bearer = HttpBuilder::new()
132 .scheme(HttpAuthScheme::Bearer)
133 .bearer_format("JWT")
134 .description(Some("OIDC access token supplied as an HTTP bearer JWT."))
135 .build();
136 components.add_security_scheme(BEARER_AUTH_SCHEME, SecurityScheme::Http(bearer));
137 }
138 }
139}
140
141#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)]
146pub struct ErrorEnvelope {
147 pub error: ErrorBody,
149}
150
151#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)]
153#[serde(rename_all = "camelCase")]
154pub struct ErrorBody {
155 pub code: String,
157 pub message: String,
159 pub request_id: String,
161 pub details: BTreeMap<String, Value>,
163}
164
165#[derive(
173 Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, ToSchema,
174)]
175#[schema(value_type = String, format = DateTime)]
176pub struct Rfc3339DateTime(#[serde(with = "time::serde::rfc3339")] pub time::OffsetDateTime);
177
178impl Rfc3339DateTime {
179 pub const fn new(value: time::OffsetDateTime) -> Self {
181 Self(value)
182 }
183
184 pub const fn into_inner(self) -> time::OffsetDateTime {
186 self.0
187 }
188}
189
190impl From<time::OffsetDateTime> for Rfc3339DateTime {
191 fn from(value: time::OffsetDateTime) -> Self {
192 Self::new(value)
193 }
194}
195
196impl From<Rfc3339DateTime> for time::OffsetDateTime {
197 fn from(value: Rfc3339DateTime) -> Self {
198 value.into_inner()
199 }
200}
201
202impl fmt::Display for Rfc3339DateTime {
203 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
204 let rendered = self
205 .0
206 .format(&time::format_description::well_known::Rfc3339)
207 .map_err(|_| fmt::Error)?;
208 formatter.write_str(&rendered)
209 }
210}
211
212impl std::str::FromStr for Rfc3339DateTime {
213 type Err = time::error::Parse;
214
215 fn from_str(value: &str) -> Result<Self, Self::Err> {
216 time::OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339).map(Self)
217 }
218}
219
220#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)]
228pub struct ResponseEnvelope<T, M> {
229 pub data: T,
231 pub meta: M,
233}
234
235impl<T, M> ResponseEnvelope<T, M> {
236 pub const fn new(data: T, meta: M) -> Self {
238 Self { data, meta }
239 }
240
241 pub fn map<U>(self, map: impl FnOnce(T) -> U) -> ResponseEnvelope<U, M> {
243 ResponseEnvelope {
244 data: map(self.data),
245 meta: self.meta,
246 }
247 }
248}
249
250#[derive(Debug)]
255pub struct SchemaError {
256 kind: SchemaErrorKind,
257}
258
259#[derive(Debug)]
260enum SchemaErrorKind {
261 Serialize(serde_json::Error),
262 Io {
263 action: &'static str,
264 path: PathBuf,
265 source: io::Error,
266 },
267 Drift {
268 path: PathBuf,
269 line: usize,
270 committed: Option<String>,
271 generated: Option<String>,
272 committed_line_count: usize,
273 generated_line_count: usize,
274 },
275 Naming(Vec<NamingViolation>),
276}
277
278impl SchemaError {
279 fn io(action: &'static str, path: &Path, source: io::Error) -> Self {
280 Self {
281 kind: SchemaErrorKind::Io {
282 action,
283 path: path.to_owned(),
284 source,
285 },
286 }
287 }
288
289 fn drift(path: &Path, committed: &str, generated: &str) -> Self {
290 let committed_lines: Vec<_> = committed.split('\n').collect();
291 let generated_lines: Vec<_> = generated.split('\n').collect();
292 let difference = committed_lines
293 .iter()
294 .zip(&generated_lines)
295 .position(|(left, right)| left != right)
296 .unwrap_or_else(|| committed_lines.len().min(generated_lines.len()));
297
298 Self {
299 kind: SchemaErrorKind::Drift {
300 path: path.to_owned(),
301 line: difference + 1,
302 committed: committed_lines.get(difference).map(ToString::to_string),
303 generated: generated_lines.get(difference).map(ToString::to_string),
304 committed_line_count: committed_lines.len(),
305 generated_line_count: generated_lines.len(),
306 },
307 }
308 }
309
310 #[must_use]
312 pub fn is_drift(&self) -> bool {
313 matches!(self.kind, SchemaErrorKind::Drift { .. })
314 }
315
316 #[must_use]
319 pub fn naming_violations(&self) -> &[NamingViolation] {
320 match &self.kind {
321 SchemaErrorKind::Naming(violations) => violations,
322 _ => &[],
323 }
324 }
325}
326
327impl fmt::Display for SchemaError {
328 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
329 match &self.kind {
330 SchemaErrorKind::Serialize(error) => {
331 write!(formatter, "could not serialize OpenAPI schema: {error}")
332 }
333 SchemaErrorKind::Io {
334 action,
335 path,
336 source,
337 } => write!(
338 formatter,
339 "could not {action} OpenAPI schema at {}: {source}",
340 path.display()
341 ),
342 SchemaErrorKind::Drift {
343 path,
344 line,
345 committed,
346 generated,
347 committed_line_count,
348 generated_line_count,
349 } => write!(
350 formatter,
351 "schema drift detected for {} (first difference at line {line}; committed: {}; generated: {}; line counts: {committed_line_count} committed, {generated_line_count} generated)\n\
352 - committed: {}\n\
353 + generated: {}\n\
354 run the openapi binary and commit the generated schema",
355 path.display(),
356 line_value(committed.as_deref()),
357 line_value(generated.as_deref()),
358 line_value(committed.as_deref()),
359 line_value(generated.as_deref()),
360 ),
361 SchemaErrorKind::Naming(violations) => {
362 write!(
363 formatter,
364 "{} OpenAPI name(s) are not camelCase; rename the serde field or exempt a standard-defined name",
365 violations.len()
366 )?;
367 violations
368 .iter()
369 .try_for_each(|violation| write!(formatter, "\n - {violation}"))
370 }
371 }
372 }
373}
374
375impl StdError for SchemaError {
376 fn source(&self) -> Option<&(dyn StdError + 'static)> {
377 match &self.kind {
378 SchemaErrorKind::Serialize(error) => Some(error),
379 SchemaErrorKind::Io { source, .. } => Some(source),
380 SchemaErrorKind::Drift { .. } | SchemaErrorKind::Naming(_) => None,
381 }
382 }
383}
384
385impl From<serde_json::Error> for SchemaError {
386 fn from(error: serde_json::Error) -> Self {
387 Self {
388 kind: SchemaErrorKind::Serialize(error),
389 }
390 }
391}
392
393fn line_value(line: Option<&str>) -> String {
394 line.map_or_else(|| "<end of file>".to_owned(), |line| format!("{line:?}"))
395}
396
397pub fn serialize_schema(openapi: &OpenApi) -> Result<String, SchemaError> {
407 let mut openapi = openapi.clone();
408 fill_missing_response_descriptions(&mut openapi);
409 let mut value = serde_json::to_value(openapi)?;
410 sort_json_objects(&mut value);
411 let mut json = serde_json::to_string_pretty(&value)?;
412 json.push('\n');
413 Ok(json)
414}
415
416fn sort_json_objects(value: &mut Value) {
417 match value {
418 Value::Array(values) => values.iter_mut().for_each(sort_json_objects),
419 Value::Object(map) => {
420 let mut entries: Vec<_> = std::mem::take(map).into_iter().collect();
421 for (_, child) in &mut entries {
422 sort_json_objects(child);
423 }
424 entries.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
425 map.extend(entries);
426 }
427 _ => {}
428 }
429}
430
431pub fn write_schema(openapi: &OpenApi, path: impl AsRef<Path>) -> Result<(), SchemaError> {
442 let path = path.as_ref();
443 let json = serialize_schema(openapi)?;
444 let parent = path
445 .parent()
446 .filter(|parent| !parent.as_os_str().is_empty())
447 .unwrap_or_else(|| Path::new("."));
448 fs::create_dir_all(parent)
449 .map_err(|error| SchemaError::io("create parent directories for", path, error))?;
450
451 let (mut temporary, temporary_path) = create_temporary_file(path, parent)?;
452 let write_result = (|| {
453 temporary
454 .write_all(json.as_bytes())
455 .map_err(|error| SchemaError::io("write temporary", &temporary_path, error))?;
456 temporary
457 .sync_all()
458 .map_err(|error| SchemaError::io("sync temporary", &temporary_path, error))?;
459 drop(temporary);
460 fs::rename(&temporary_path, path).map_err(|error| SchemaError::io("replace", path, error))
461 })();
462
463 if write_result.is_err() {
464 let _ignored = fs::remove_file(&temporary_path);
465 }
466 write_result
467}
468
469static TEMPORARY_FILE_COUNTER: AtomicU64 = AtomicU64::new(0);
470
471fn create_temporary_file(path: &Path, parent: &Path) -> Result<(File, PathBuf), SchemaError> {
472 let file_name = path.file_name().ok_or_else(|| {
473 SchemaError::io(
474 "resolve destination for",
475 path,
476 io::Error::new(io::ErrorKind::InvalidInput, "path has no file name"),
477 )
478 })?;
479
480 for _attempt in 0..100 {
481 let sequence = TEMPORARY_FILE_COUNTER.fetch_add(1, Ordering::Relaxed);
482 let temporary_path = parent.join(format!(
483 ".{}.{}.{}.tmp",
484 file_name.to_string_lossy(),
485 std::process::id(),
486 sequence
487 ));
488 match OpenOptions::new()
489 .write(true)
490 .create_new(true)
491 .open(&temporary_path)
492 {
493 Ok(file) => return Ok((file, temporary_path)),
494 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
495 Err(error) => {
496 return Err(SchemaError::io("create temporary", &temporary_path, error));
497 }
498 }
499 }
500
501 Err(SchemaError::io(
502 "create temporary",
503 path,
504 io::Error::new(
505 io::ErrorKind::AlreadyExists,
506 "could not allocate a unique temporary file name",
507 ),
508 ))
509}
510
511pub fn check_no_drift(
522 openapi: &OpenApi,
523 committed_path: impl AsRef<Path>,
524) -> Result<(), SchemaError> {
525 let committed_path = committed_path.as_ref();
526 let generated = serialize_schema(openapi)?;
527 let committed = match fs::read_to_string(committed_path) {
528 Ok(committed) => committed,
529 Err(error) if error.kind() == io::ErrorKind::NotFound => String::new(),
530 Err(error) => return Err(SchemaError::io("read committed", committed_path, error)),
531 };
532
533 if committed == generated {
534 Ok(())
535 } else {
536 Err(SchemaError::drift(committed_path, &committed, &generated))
537 }
538}
539
540#[track_caller]
547#[expect(
548 clippy::panic,
549 reason = "assertion helper: panicking is its documented contract; the check_* function is the fallible form"
550)]
551pub fn assert_no_drift(openapi: &OpenApi, committed_path: impl AsRef<Path>) {
552 if let Err(error) = check_no_drift(openapi, committed_path) {
553 panic!("{error}");
554 }
555}
556
557pub fn check_camel_case_names(openapi: &OpenApi, exemptions: &[&str]) -> Result<(), SchemaError> {
567 let document = serde_json::to_value(openapi)?;
568 let violations = find_naming_violations(&document, exemptions);
569 if violations.is_empty() {
570 Ok(())
571 } else {
572 Err(SchemaError {
573 kind: SchemaErrorKind::Naming(violations),
574 })
575 }
576}
577
578#[track_caller]
585#[expect(
586 clippy::panic,
587 reason = "assertion helper: panicking is its documented contract; the check_* function is the fallible form"
588)]
589pub fn assert_camel_case_names(openapi: &OpenApi, exemptions: &[&str]) {
590 if let Err(error) = check_camel_case_names(openapi, exemptions) {
591 panic!("{error}");
592 }
593}
594
595#[cfg(test)]
596mod tests {
597 use std::collections::BTreeMap;
598 use std::fs;
599
600 use serde::{Deserialize, Serialize};
601 use serde_json::{Value, json};
602 use tempfile::tempdir;
603 use utoipa::openapi::extensions::Extensions;
604 use utoipa::openapi::security::{HttpAuthScheme, SecurityScheme};
605 use utoipa::openapi::{Info, OpenApi, Paths, RefOr, Server};
606 use utoipa::{PartialSchema, ToSchema};
607
608 use super::{
609 BEARER_AUTH_SCHEME, ErrorBody, ErrorEnvelope, OpenApiMetadata, ResponseEnvelope,
610 Rfc3339DateTime, assert_no_drift, check_no_drift, serialize_schema, write_schema,
611 };
612
613 fn document() -> OpenApi {
614 OpenApi::new(Info::new("Unconfigured", "0.0.0"), Paths::new())
615 }
616
617 #[test]
618 fn metadata_applies_identity_servers_and_opted_in_bearer_scheme() {
619 let mut openapi = document();
620 openapi.info.contact = Some(
621 utoipa::openapi::ContactBuilder::new()
622 .name(Some("Support"))
623 .build(),
624 );
625 OpenApiMetadata::new("Inventory API", "2.4.0", "Inventory operations")
626 .servers([
627 Server::new("https://api.example.com"),
628 Server::new("http://localhost:3000"),
629 ])
630 .bearer_auth()
631 .apply_to(&mut openapi);
632
633 assert_eq!(openapi.info.title, "Inventory API");
634 assert_eq!(openapi.info.version, "2.4.0");
635 assert_eq!(
636 openapi.info.description.as_deref(),
637 Some("Inventory operations")
638 );
639 assert!(openapi.info.contact.is_some());
640 let servers = openapi.servers.as_deref();
641 assert_eq!(servers.map(|items| items.len()), Some(2));
642 assert_eq!(
643 servers
644 .and_then(|items| items.first())
645 .map(|server| server.url.as_str()),
646 Some("https://api.example.com")
647 );
648
649 let scheme = openapi
650 .components
651 .as_ref()
652 .and_then(|components| components.security_schemes.get(BEARER_AUTH_SCHEME));
653 match scheme {
654 Some(RefOr::T(SecurityScheme::Http(http))) => {
655 assert_eq!(http.bearer_format.as_deref(), Some("JWT"));
656 assert!(matches!(&http.scheme, HttpAuthScheme::Bearer));
657 }
658 _ => panic!("expected the standard HTTP bearer scheme"),
659 }
660 assert!(openapi.security.is_none());
661 }
662
663 #[test]
664 fn metadata_defaults_to_relative_server() {
665 let mut openapi = document();
666 OpenApiMetadata::new("Inventory API", "2.4.0", "Inventory operations")
667 .apply_to(&mut openapi);
668 assert_eq!(
669 openapi
670 .servers
671 .as_ref()
672 .and_then(|servers| servers.first())
673 .map(|server| server.url.as_str()),
674 Some("/")
675 );
676 assert!(openapi.components.as_ref().is_none_or(|components| {
677 !components.security_schemes.contains_key(BEARER_AUTH_SCHEME)
678 }));
679 }
680
681 #[test]
682 fn error_envelope_serializes_to_the_standard_shape() -> Result<(), Box<dyn std::error::Error>> {
683 let envelope = ErrorEnvelope {
684 error: ErrorBody {
685 code: "validation_failed".to_owned(),
686 message: "The request is invalid".to_owned(),
687 request_id: "req-123".to_owned(),
688 details: BTreeMap::new(),
689 },
690 };
691
692 assert_eq!(
693 serde_json::to_value(envelope)?,
694 json!({
695 "error": {
696 "code": "validation_failed",
697 "message": "The request is invalid",
698 "requestId": "req-123",
699 "details": {}
700 }
701 })
702 );
703 Ok(())
704 }
705
706 #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)]
707 #[serde(rename_all = "camelCase")]
708 struct TimestampDto {
709 observed_at: Rfc3339DateTime,
710 }
711
712 #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)]
713 #[serde(rename_all = "camelCase")]
714 struct TestMeta {
715 request_id: String,
716 }
717
718 #[test]
719 fn api_datetime_uses_rfc3339_json_and_date_time_schema()
720 -> Result<(), Box<dyn std::error::Error>> {
721 let timestamp = "2026-08-09T12:34:56.123456789+02:00".parse::<Rfc3339DateTime>()?;
722 let dto = TimestampDto {
723 observed_at: timestamp,
724 };
725
726 let json = serde_json::to_value(&dto)?;
727 assert_eq!(json["observedAt"], "2026-08-09T12:34:56.123456789+02:00");
728 assert_eq!(serde_json::from_value::<TimestampDto>(json)?, dto);
729
730 let schema = serde_json::to_value(Rfc3339DateTime::schema())?;
731 assert_eq!(schema["type"], "string");
732 assert_eq!(schema["format"], "date-time");
733 Ok(())
734 }
735
736 #[test]
737 fn typed_response_envelope_keeps_concrete_payload_and_metadata_schemas()
738 -> Result<(), Box<dyn std::error::Error>> {
739 let schema = serde_json::to_value(
740 <ResponseEnvelope<TimestampDto, TestMeta> as PartialSchema>::schema(),
741 )?;
742 let rendered = schema.to_string();
743
744 assert!(rendered.contains("TimestampDto"), "{rendered}");
745 assert!(rendered.contains("TestMeta"), "{rendered}");
746 assert!(!rendered.contains("serde_json.Value"), "{rendered}");
747 Ok(())
748 }
749
750 #[test]
751 fn serialization_is_stable_and_recursively_sorts_maps() -> Result<(), Box<dyn std::error::Error>>
752 {
753 let mut first = document();
754 first.extensions = Some(
755 [
756 ("x-zeta", json!({"second": 2, "first": 1})),
757 ("x-alpha", json!({"z": 2, "a": 1})),
758 ]
759 .into_iter()
760 .collect::<Extensions>(),
761 );
762
763 let mut second = document();
764 second.extensions = Some(
765 [
766 ("x-alpha", json!({"a": 1, "z": 2})),
767 ("x-zeta", json!({"first": 1, "second": 2})),
768 ]
769 .into_iter()
770 .collect::<Extensions>(),
771 );
772
773 let first_once = serialize_schema(&first)?;
774 let first_twice = serialize_schema(&first)?;
775 assert_eq!(first_once, first_twice);
776 assert_eq!(first_once, serialize_schema(&second)?);
777 assert!(first_once.ends_with('\n'));
778
779 let parsed: Value = serde_json::from_str(&first_once)?;
780 assert_eq!(parsed["x-alpha"], json!({"a": 1, "z": 2}));
781 assert!(first_once.find("\"x-alpha\"") < first_once.find("\"x-zeta\""));
782 Ok(())
783 }
784
785 #[test]
786 fn writer_creates_parents_and_round_trips() -> Result<(), Box<dyn std::error::Error>> {
787 let directory = tempdir()?;
788 let path = directory.path().join("generated/openapi.json");
789 let openapi = document();
790
791 write_schema(&openapi, &path)?;
792
793 assert_eq!(fs::read_to_string(&path)?, serialize_schema(&openapi)?);
794 Ok(())
795 }
796
797 #[test]
798 fn drift_check_accepts_matching_schema_and_rejects_changes_in_both_directions()
799 -> Result<(), Box<dyn std::error::Error>> {
800 let directory = tempdir()?;
801 let path = directory.path().join("openapi.json");
802 let base = document();
803 write_schema(&base, &path)?;
804 check_no_drift(&base, &path)?;
805 assert_no_drift(&base, &path);
806
807 let mut generated_has_more = document();
808 generated_has_more.info.description = Some("Additional documentation".to_owned());
809 let Err(error) = check_no_drift(&generated_has_more, &path) else {
810 panic!("generated addition should be detected as drift");
811 };
812 assert!(error.is_drift());
813 assert!(error.to_string().contains("schema drift detected"));
814 assert!(
815 error
816 .to_string()
817 .contains("run the openapi binary and commit")
818 );
819
820 write_schema(&generated_has_more, &path)?;
821 let Err(error) = check_no_drift(&base, &path) else {
822 panic!("generated removal should be detected as drift");
823 };
824 assert!(error.is_drift());
825 Ok(())
826 }
827
828 #[test]
829 fn missing_committed_schema_is_drift() -> Result<(), Box<dyn std::error::Error>> {
830 let directory = tempdir()?;
831 let Err(error) = check_no_drift(&document(), directory.path().join("missing.json")) else {
832 panic!("a missing committed schema should be detected as drift");
833 };
834 assert!(error.is_drift());
835 Ok(())
836 }
837}
838
839#[doc = include_str!("../README.md")]
841#[cfg(doctest)]
842struct ReadmeDoctests;