1#![deny(missing_docs)]
28
29use std::collections::BTreeMap;
30use std::error::Error as StdError;
31use std::fmt;
32use std::fs::{self, File, OpenOptions};
33use std::io::{self, Write};
34use std::path::{Path, PathBuf};
35use std::sync::atomic::{AtomicU64, Ordering};
36
37use serde::{Deserialize, Serialize};
38use serde_json::Value;
39use utoipa::ToSchema;
40use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
41use utoipa::openapi::{Components, OpenApi, Server};
42
43pub const BEARER_AUTH_SCHEME: &str = "bearerAuth";
45
46#[derive(Clone)]
54pub struct OpenApiMetadata {
55 title: String,
56 version: String,
57 description: String,
58 servers: Vec<Server>,
59 bearer_auth: bool,
60}
61
62impl OpenApiMetadata {
63 pub fn new<T, V, D>(title: T, version: V, description: D) -> Self
65 where
66 T: Into<String>,
67 V: Into<String>,
68 D: Into<String>,
69 {
70 Self {
71 title: title.into(),
72 version: version.into(),
73 description: description.into(),
74 servers: vec![Server::new("/")],
75 bearer_auth: false,
76 }
77 }
78
79 #[must_use]
84 pub fn servers<I>(mut self, servers: I) -> Self
85 where
86 I: IntoIterator<Item = Server>,
87 {
88 self.servers = servers.into_iter().collect();
89 self
90 }
91
92 #[must_use]
97 pub const fn bearer_auth(mut self) -> Self {
98 self.bearer_auth = true;
99 self
100 }
101
102 pub fn apply_to(&self, openapi: &mut OpenApi) {
104 openapi.info.title.clone_from(&self.title);
105 openapi.info.version.clone_from(&self.version);
106 openapi.info.description = Some(self.description.clone());
107 openapi.servers = (!self.servers.is_empty()).then(|| self.servers.clone());
108
109 if self.bearer_auth {
110 let components = openapi.components.get_or_insert_with(Components::new);
111 let bearer = HttpBuilder::new()
112 .scheme(HttpAuthScheme::Bearer)
113 .bearer_format("JWT")
114 .description(Some("OIDC access token supplied as an HTTP bearer JWT."))
115 .build();
116 components.add_security_scheme(BEARER_AUTH_SCHEME, SecurityScheme::Http(bearer));
117 }
118 }
119}
120
121#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)]
126pub struct ErrorEnvelope {
127 pub error: ErrorBody,
129}
130
131#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)]
133pub struct ErrorBody {
134 pub code: String,
136 pub message: String,
138 pub request_id: String,
140 pub details: BTreeMap<String, Value>,
142}
143
144#[derive(
152 Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, ToSchema,
153)]
154#[schema(value_type = String, format = DateTime)]
155pub struct Rfc3339DateTime(#[serde(with = "time::serde::rfc3339")] pub time::OffsetDateTime);
156
157impl Rfc3339DateTime {
158 pub const fn new(value: time::OffsetDateTime) -> Self {
160 Self(value)
161 }
162
163 pub const fn into_inner(self) -> time::OffsetDateTime {
165 self.0
166 }
167}
168
169impl From<time::OffsetDateTime> for Rfc3339DateTime {
170 fn from(value: time::OffsetDateTime) -> Self {
171 Self::new(value)
172 }
173}
174
175impl From<Rfc3339DateTime> for time::OffsetDateTime {
176 fn from(value: Rfc3339DateTime) -> Self {
177 value.into_inner()
178 }
179}
180
181impl fmt::Display for Rfc3339DateTime {
182 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
183 let rendered = self
184 .0
185 .format(&time::format_description::well_known::Rfc3339)
186 .map_err(|_| fmt::Error)?;
187 formatter.write_str(&rendered)
188 }
189}
190
191impl std::str::FromStr for Rfc3339DateTime {
192 type Err = time::error::Parse;
193
194 fn from_str(value: &str) -> Result<Self, Self::Err> {
195 time::OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339).map(Self)
196 }
197}
198
199#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)]
207pub struct ResponseEnvelope<T, M> {
208 pub data: T,
210 pub meta: M,
212}
213
214impl<T, M> ResponseEnvelope<T, M> {
215 pub const fn new(data: T, meta: M) -> Self {
217 Self { data, meta }
218 }
219
220 pub fn map<U>(self, map: impl FnOnce(T) -> U) -> ResponseEnvelope<U, M> {
222 ResponseEnvelope {
223 data: map(self.data),
224 meta: self.meta,
225 }
226 }
227}
228
229#[derive(Debug)]
234pub struct SchemaError {
235 kind: SchemaErrorKind,
236}
237
238#[derive(Debug)]
239enum SchemaErrorKind {
240 Serialize(serde_json::Error),
241 Io {
242 action: &'static str,
243 path: PathBuf,
244 source: io::Error,
245 },
246 Drift {
247 path: PathBuf,
248 line: usize,
249 committed: Option<String>,
250 generated: Option<String>,
251 committed_line_count: usize,
252 generated_line_count: usize,
253 },
254}
255
256impl SchemaError {
257 fn io(action: &'static str, path: &Path, source: io::Error) -> Self {
258 Self {
259 kind: SchemaErrorKind::Io {
260 action,
261 path: path.to_owned(),
262 source,
263 },
264 }
265 }
266
267 fn drift(path: &Path, committed: &str, generated: &str) -> Self {
268 let committed_lines: Vec<_> = committed.split('\n').collect();
269 let generated_lines: Vec<_> = generated.split('\n').collect();
270 let difference = committed_lines
271 .iter()
272 .zip(&generated_lines)
273 .position(|(left, right)| left != right)
274 .unwrap_or_else(|| committed_lines.len().min(generated_lines.len()));
275
276 Self {
277 kind: SchemaErrorKind::Drift {
278 path: path.to_owned(),
279 line: difference + 1,
280 committed: committed_lines.get(difference).map(ToString::to_string),
281 generated: generated_lines.get(difference).map(ToString::to_string),
282 committed_line_count: committed_lines.len(),
283 generated_line_count: generated_lines.len(),
284 },
285 }
286 }
287
288 #[must_use]
290 pub fn is_drift(&self) -> bool {
291 matches!(self.kind, SchemaErrorKind::Drift { .. })
292 }
293}
294
295impl fmt::Display for SchemaError {
296 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
297 match &self.kind {
298 SchemaErrorKind::Serialize(error) => {
299 write!(formatter, "could not serialize OpenAPI schema: {error}")
300 }
301 SchemaErrorKind::Io {
302 action,
303 path,
304 source,
305 } => write!(
306 formatter,
307 "could not {action} OpenAPI schema at {}: {source}",
308 path.display()
309 ),
310 SchemaErrorKind::Drift {
311 path,
312 line,
313 committed,
314 generated,
315 committed_line_count,
316 generated_line_count,
317 } => write!(
318 formatter,
319 "schema drift detected for {} (first difference at line {line}; committed: {}; generated: {}; line counts: {committed_line_count} committed, {generated_line_count} generated)\n\
320 - committed: {}\n\
321 + generated: {}\n\
322 run the openapi binary and commit the generated schema",
323 path.display(),
324 line_value(committed.as_deref()),
325 line_value(generated.as_deref()),
326 line_value(committed.as_deref()),
327 line_value(generated.as_deref()),
328 ),
329 }
330 }
331}
332
333impl StdError for SchemaError {
334 fn source(&self) -> Option<&(dyn StdError + 'static)> {
335 match &self.kind {
336 SchemaErrorKind::Serialize(error) => Some(error),
337 SchemaErrorKind::Io { source, .. } => Some(source),
338 SchemaErrorKind::Drift { .. } => None,
339 }
340 }
341}
342
343impl From<serde_json::Error> for SchemaError {
344 fn from(error: serde_json::Error) -> Self {
345 Self {
346 kind: SchemaErrorKind::Serialize(error),
347 }
348 }
349}
350
351fn line_value(line: Option<&str>) -> String {
352 line.map_or_else(|| "<end of file>".to_owned(), |line| format!("{line:?}"))
353}
354
355pub fn serialize_schema(openapi: &OpenApi) -> Result<String, SchemaError> {
360 let mut value = serde_json::to_value(openapi)?;
361 sort_json_objects(&mut value);
362 let mut json = serde_json::to_string_pretty(&value)?;
363 json.push('\n');
364 Ok(json)
365}
366
367fn sort_json_objects(value: &mut Value) {
368 match value {
369 Value::Array(values) => values.iter_mut().for_each(sort_json_objects),
370 Value::Object(map) => {
371 let mut entries: Vec<_> = std::mem::take(map).into_iter().collect();
372 for (_, child) in &mut entries {
373 sort_json_objects(child);
374 }
375 entries.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
376 map.extend(entries);
377 }
378 _ => {}
379 }
380}
381
382pub fn write_schema(openapi: &OpenApi, path: impl AsRef<Path>) -> Result<(), SchemaError> {
387 let path = path.as_ref();
388 let json = serialize_schema(openapi)?;
389 let parent = path
390 .parent()
391 .filter(|parent| !parent.as_os_str().is_empty())
392 .unwrap_or_else(|| Path::new("."));
393 fs::create_dir_all(parent)
394 .map_err(|error| SchemaError::io("create parent directories for", path, error))?;
395
396 let (mut temporary, temporary_path) = create_temporary_file(path, parent)?;
397 let write_result = (|| {
398 temporary
399 .write_all(json.as_bytes())
400 .map_err(|error| SchemaError::io("write temporary", &temporary_path, error))?;
401 temporary
402 .sync_all()
403 .map_err(|error| SchemaError::io("sync temporary", &temporary_path, error))?;
404 drop(temporary);
405 fs::rename(&temporary_path, path).map_err(|error| SchemaError::io("replace", path, error))
406 })();
407
408 if write_result.is_err() {
409 let _ignored = fs::remove_file(&temporary_path);
410 }
411 write_result
412}
413
414static TEMPORARY_FILE_COUNTER: AtomicU64 = AtomicU64::new(0);
415
416fn create_temporary_file(path: &Path, parent: &Path) -> Result<(File, PathBuf), SchemaError> {
417 let file_name = path.file_name().ok_or_else(|| {
418 SchemaError::io(
419 "resolve destination for",
420 path,
421 io::Error::new(io::ErrorKind::InvalidInput, "path has no file name"),
422 )
423 })?;
424
425 for _attempt in 0..100 {
426 let sequence = TEMPORARY_FILE_COUNTER.fetch_add(1, Ordering::Relaxed);
427 let temporary_path = parent.join(format!(
428 ".{}.{}.{}.tmp",
429 file_name.to_string_lossy(),
430 std::process::id(),
431 sequence
432 ));
433 match OpenOptions::new()
434 .write(true)
435 .create_new(true)
436 .open(&temporary_path)
437 {
438 Ok(file) => return Ok((file, temporary_path)),
439 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
440 Err(error) => {
441 return Err(SchemaError::io("create temporary", &temporary_path, error));
442 }
443 }
444 }
445
446 Err(SchemaError::io(
447 "create temporary",
448 path,
449 io::Error::new(
450 io::ErrorKind::AlreadyExists,
451 "could not allocate a unique temporary file name",
452 ),
453 ))
454}
455
456pub fn check_no_drift(
461 openapi: &OpenApi,
462 committed_path: impl AsRef<Path>,
463) -> Result<(), SchemaError> {
464 let committed_path = committed_path.as_ref();
465 let generated = serialize_schema(openapi)?;
466 let committed = match fs::read_to_string(committed_path) {
467 Ok(committed) => committed,
468 Err(error) if error.kind() == io::ErrorKind::NotFound => String::new(),
469 Err(error) => return Err(SchemaError::io("read committed", committed_path, error)),
470 };
471
472 if committed == generated {
473 Ok(())
474 } else {
475 Err(SchemaError::drift(committed_path, &committed, &generated))
476 }
477}
478
479#[track_caller]
486pub fn assert_no_drift(openapi: &OpenApi, committed_path: impl AsRef<Path>) {
487 if let Err(error) = check_no_drift(openapi, committed_path) {
488 panic!("{error}");
489 }
490}
491
492#[cfg(test)]
493mod tests {
494 use std::collections::BTreeMap;
495 use std::fs;
496
497 use serde::{Deserialize, Serialize};
498 use serde_json::{Value, json};
499 use tempfile::tempdir;
500 use utoipa::openapi::extensions::Extensions;
501 use utoipa::openapi::security::{HttpAuthScheme, SecurityScheme};
502 use utoipa::openapi::{Info, OpenApi, Paths, Server};
503 use utoipa::{PartialSchema, ToSchema};
504
505 use super::{
506 BEARER_AUTH_SCHEME, ErrorBody, ErrorEnvelope, OpenApiMetadata, ResponseEnvelope,
507 Rfc3339DateTime, assert_no_drift, check_no_drift, serialize_schema, write_schema,
508 };
509
510 fn document() -> OpenApi {
511 OpenApi::new(Info::new("Unconfigured", "0.0.0"), Paths::new())
512 }
513
514 #[test]
515 fn metadata_applies_identity_servers_and_opted_in_bearer_scheme() {
516 let mut openapi = document();
517 openapi.info.contact = Some(
518 utoipa::openapi::ContactBuilder::new()
519 .name(Some("Support"))
520 .build(),
521 );
522 OpenApiMetadata::new("Inventory API", "2.4.0", "Inventory operations")
523 .servers([
524 Server::new("https://api.example.com"),
525 Server::new("http://localhost:3000"),
526 ])
527 .bearer_auth()
528 .apply_to(&mut openapi);
529
530 assert_eq!(openapi.info.title, "Inventory API");
531 assert_eq!(openapi.info.version, "2.4.0");
532 assert_eq!(
533 openapi.info.description.as_deref(),
534 Some("Inventory operations")
535 );
536 assert!(openapi.info.contact.is_some());
537 let servers = openapi.servers.as_deref();
538 assert_eq!(servers.map(|items| items.len()), Some(2));
539 assert_eq!(
540 servers
541 .and_then(|items| items.first())
542 .map(|server| server.url.as_str()),
543 Some("https://api.example.com")
544 );
545
546 let scheme = openapi
547 .components
548 .as_ref()
549 .and_then(|components| components.security_schemes.get(BEARER_AUTH_SCHEME));
550 match scheme {
551 Some(SecurityScheme::Http(http)) => {
552 assert_eq!(http.bearer_format.as_deref(), Some("JWT"));
553 assert!(matches!(&http.scheme, HttpAuthScheme::Bearer));
554 }
555 _ => panic!("expected the standard HTTP bearer scheme"),
556 }
557 assert!(openapi.security.is_none());
558 }
559
560 #[test]
561 fn metadata_defaults_to_relative_server() {
562 let mut openapi = document();
563 OpenApiMetadata::new("Inventory API", "2.4.0", "Inventory operations")
564 .apply_to(&mut openapi);
565 assert_eq!(
566 openapi
567 .servers
568 .as_ref()
569 .and_then(|servers| servers.first())
570 .map(|server| server.url.as_str()),
571 Some("/")
572 );
573 assert!(openapi.components.as_ref().is_none_or(|components| {
574 !components.security_schemes.contains_key(BEARER_AUTH_SCHEME)
575 }));
576 }
577
578 #[test]
579 fn error_envelope_serializes_to_the_standard_shape() -> Result<(), Box<dyn std::error::Error>> {
580 let envelope = ErrorEnvelope {
581 error: ErrorBody {
582 code: "validation_failed".to_owned(),
583 message: "The request is invalid".to_owned(),
584 request_id: "req-123".to_owned(),
585 details: BTreeMap::new(),
586 },
587 };
588
589 assert_eq!(
590 serde_json::to_value(envelope)?,
591 json!({
592 "error": {
593 "code": "validation_failed",
594 "message": "The request is invalid",
595 "request_id": "req-123",
596 "details": {}
597 }
598 })
599 );
600 Ok(())
601 }
602
603 #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)]
604 struct TimestampDto {
605 observed_at: Rfc3339DateTime,
606 }
607
608 #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)]
609 struct TestMeta {
610 request_id: String,
611 }
612
613 #[test]
614 fn api_datetime_uses_rfc3339_json_and_date_time_schema()
615 -> Result<(), Box<dyn std::error::Error>> {
616 let timestamp = "2026-08-09T12:34:56.123456789+02:00".parse::<Rfc3339DateTime>()?;
617 let dto = TimestampDto {
618 observed_at: timestamp,
619 };
620
621 let json = serde_json::to_value(&dto)?;
622 assert_eq!(json["observed_at"], "2026-08-09T12:34:56.123456789+02:00");
623 assert_eq!(serde_json::from_value::<TimestampDto>(json)?, dto);
624
625 let schema = serde_json::to_value(Rfc3339DateTime::schema())?;
626 assert_eq!(schema["type"], "string");
627 assert_eq!(schema["format"], "date-time");
628 Ok(())
629 }
630
631 #[test]
632 fn typed_response_envelope_keeps_concrete_payload_and_metadata_schemas()
633 -> Result<(), Box<dyn std::error::Error>> {
634 let schema = serde_json::to_value(
635 <ResponseEnvelope<TimestampDto, TestMeta> as PartialSchema>::schema(),
636 )?;
637 let rendered = schema.to_string();
638
639 assert!(rendered.contains("TimestampDto"), "{rendered}");
640 assert!(rendered.contains("TestMeta"), "{rendered}");
641 assert!(!rendered.contains("serde_json.Value"), "{rendered}");
642 Ok(())
643 }
644
645 #[test]
646 fn serialization_is_stable_and_recursively_sorts_maps() -> Result<(), Box<dyn std::error::Error>>
647 {
648 let mut first = document();
649 first.extensions = Some(
650 [
651 ("x-zeta", json!({"second": 2, "first": 1})),
652 ("x-alpha", json!({"z": 2, "a": 1})),
653 ]
654 .into_iter()
655 .collect::<Extensions>(),
656 );
657
658 let mut second = document();
659 second.extensions = Some(
660 [
661 ("x-alpha", json!({"a": 1, "z": 2})),
662 ("x-zeta", json!({"first": 1, "second": 2})),
663 ]
664 .into_iter()
665 .collect::<Extensions>(),
666 );
667
668 let first_once = serialize_schema(&first)?;
669 let first_twice = serialize_schema(&first)?;
670 assert_eq!(first_once, first_twice);
671 assert_eq!(first_once, serialize_schema(&second)?);
672 assert!(first_once.ends_with('\n'));
673
674 let parsed: Value = serde_json::from_str(&first_once)?;
675 assert_eq!(parsed["x-alpha"], json!({"a": 1, "z": 2}));
676 assert!(first_once.find("\"x-alpha\"") < first_once.find("\"x-zeta\""));
677 Ok(())
678 }
679
680 #[test]
681 fn writer_creates_parents_and_round_trips() -> Result<(), Box<dyn std::error::Error>> {
682 let directory = tempdir()?;
683 let path = directory.path().join("generated/openapi.json");
684 let openapi = document();
685
686 write_schema(&openapi, &path)?;
687
688 assert_eq!(fs::read_to_string(&path)?, serialize_schema(&openapi)?);
689 Ok(())
690 }
691
692 #[test]
693 fn drift_check_accepts_matching_schema_and_rejects_changes_in_both_directions()
694 -> Result<(), Box<dyn std::error::Error>> {
695 let directory = tempdir()?;
696 let path = directory.path().join("openapi.json");
697 let base = document();
698 write_schema(&base, &path)?;
699 check_no_drift(&base, &path)?;
700 assert_no_drift(&base, &path);
701
702 let mut generated_has_more = document();
703 generated_has_more.info.description = Some("Additional documentation".to_owned());
704 let error = match check_no_drift(&generated_has_more, &path) {
705 Err(error) => error,
706 Ok(()) => panic!("generated addition should be detected as drift"),
707 };
708 assert!(error.is_drift());
709 assert!(error.to_string().contains("schema drift detected"));
710 assert!(
711 error
712 .to_string()
713 .contains("run the openapi binary and commit")
714 );
715
716 write_schema(&generated_has_more, &path)?;
717 let error = match check_no_drift(&base, &path) {
718 Err(error) => error,
719 Ok(()) => panic!("generated removal should be detected as drift"),
720 };
721 assert!(error.is_drift());
722 Ok(())
723 }
724
725 #[test]
726 fn missing_committed_schema_is_drift() -> Result<(), Box<dyn std::error::Error>> {
727 let directory = tempdir()?;
728 let error = match check_no_drift(&document(), directory.path().join("missing.json")) {
729 Err(error) => error,
730 Ok(()) => panic!("a missing committed schema should be detected as drift"),
731 };
732 assert!(error.is_drift());
733 Ok(())
734 }
735}