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