Skip to main content

baukit_openapi/
lib.rs

1//! Small, product-neutral helpers for consistent OpenAPI documents.
2//!
3//! Products continue to own paths, operations, and endpoint schemas. This crate only applies
4//! Baukit's document conventions, provides the shared error envelope schema, documents error
5//! responses from product rules, offers opt-in bearer authentication metadata, manages a
6//! deterministic committed schema, and checks that property and parameter names are camelCase.
7//!
8//! # Example
9//!
10//! ```rust
11//! use baukit_openapi::{ErrorEnvelope, OpenApiMetadata, serialize_schema};
12//! use utoipa::openapi::Server;
13//!
14//! #[derive(utoipa::OpenApi)]
15//! #[openapi(components(schemas(ErrorEnvelope)))]
16//! struct ApiDoc;
17//!
18//! let mut document = <ApiDoc as utoipa::OpenApi>::openapi();
19//! OpenApiMetadata::new("Orders API", "1.2.3", "The Orders service API")
20//!     .servers([Server::new("https://api.example.com")])
21//!     .apply_to(&mut document);
22//!
23//! let json = serialize_schema(&document)?;
24//! assert!(json.ends_with('\n'));
25//! # Ok::<(), baukit_openapi::SchemaError>(())
26//! ```
27
28#![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
62/// The component name used for Baukit's standard HTTP bearer JWT security scheme.
63pub const BEARER_AUTH_SCHEME: &str = "bearerAuth";
64
65/// Baukit document metadata and optional security conventions.
66///
67/// New metadata uses `/` as its server, allowing the same schema to describe the service at any
68/// deployment origin. Use [`OpenApiMetadata::servers`] when a product has explicit server URLs.
69/// Applying metadata preserves product-owned paths, schemas, contact information, license, and
70/// existing security schemes. Call [`OpenApiMetadata::bearer_auth`] to add
71/// Baukit's standard bearer JWT component; unauthenticated APIs omit it by default.
72#[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    /// Creates metadata from a service's display title, version, and safe public description.
83    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    /// Replaces the default relative server with the supplied server entries.
99    ///
100    /// Passing an empty iterator omits the explicit server list and restores OpenAPI's implicit
101    /// `/` server behavior.
102    #[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    /// Adds Baukit's standard HTTP bearer JWT security-scheme component.
112    ///
113    /// This only registers the reusable component named [`BEARER_AUTH_SCHEME`];
114    /// products still decide which operations or documents require it.
115    #[must_use]
116    pub const fn bearer_auth(mut self) -> Self {
117        self.bearer_auth = true;
118        self
119    }
120
121    /// Applies metadata, the server list, and any opted-in conventions to a document.
122    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/// The shared public error response envelope.
142///
143/// This type deliberately has no HTTP-framework dependency so runtime crates can serialize and
144/// reuse it directly.
145#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)]
146pub struct ErrorEnvelope {
147    /// The structured public error.
148    pub error: ErrorBody,
149}
150
151/// The error body nested inside [`ErrorEnvelope`].
152#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)]
153#[serde(rename_all = "camelCase")]
154pub struct ErrorBody {
155    /// Stable, machine-readable error code.
156    pub code: String,
157    /// Safe user-facing or intentionally generic message.
158    pub message: String,
159    /// Request identifier shared with logs and support tooling.
160    pub request_id: String,
161    /// Structured product- or validation-specific error details.
162    pub details: BTreeMap<String, Value>,
163}
164
165/// An API timestamp with one JSON and OpenAPI representation: an RFC 3339
166/// string with OpenAPI `date-time` format.
167///
168/// Use this at DTO boundaries around a domain [`time::OffsetDateTime`]. The
169/// wrapper prevents `time`'s default non-human-readable nine-element tuple from
170/// leaking into JSON while keeping conversion to and from the domain type
171/// explicit and lossless.
172#[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    /// Wraps a domain timestamp for use in an API DTO.
180    pub const fn new(value: time::OffsetDateTime) -> Self {
181        Self(value)
182    }
183
184    /// Returns the wrapped domain timestamp.
185    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/// A fully typed success-response envelope.
221///
222/// Both `data` and `meta` remain concrete generic schema arguments. Products
223/// should define DTOs for them rather than substituting [`serde_json::Value`],
224/// so generated clients retain the complete response contract. A paginated
225/// response uses `Vec<ItemDto>` as `T` and a product-owned pagination metadata
226/// DTO as `M`.
227#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)]
228pub struct ResponseEnvelope<T, M> {
229    /// The typed response payload.
230    pub data: T,
231    /// Typed response metadata, such as request ID and pagination cursor.
232    pub meta: M,
233}
234
235impl<T, M> ResponseEnvelope<T, M> {
236    /// Creates a fully typed response envelope.
237    pub const fn new(data: T, meta: M) -> Self {
238        Self { data, meta }
239    }
240
241    /// Maps the payload without weakening the metadata type.
242    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/// An error produced while serializing, writing, comparing, or naming-checking an OpenAPI schema.
251///
252/// Its display text includes the affected path and, for drift, the first differing line together
253/// with the CI remediation instruction. A naming failure lists every violation.
254#[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    /// Returns `true` when the committed schema differs from the generated document.
311    #[must_use]
312    pub fn is_drift(&self) -> bool {
313        matches!(self.kind, SchemaErrorKind::Drift { .. })
314    }
315
316    /// Returns the names that failed [`check_camel_case_names`], or an empty slice for any other
317    /// error.
318    #[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
397/// Serializes an OpenAPI document as deterministically ordered pretty JSON with a trailing newline.
398///
399/// Object keys are recursively sorted after Utoipa serialization. This also stabilizes extension
400/// maps whose backing collection can otherwise have nondeterministic iteration order.
401/// Missing response descriptions are filled in a copy of the document before serialization.
402///
403/// # Errors
404///
405/// Returns a [`SchemaError`] when the document cannot be serialized to JSON.
406pub 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
431/// Writes a deterministic schema atomically, creating parent directories when necessary.
432///
433/// The temporary file is created beside the destination so the final rename stays on the same
434/// filesystem.
435///
436/// # Errors
437///
438/// Returns a [`SchemaError`] when the document cannot be serialized, the parent
439/// directories or temporary file cannot be created, or writing, syncing or
440/// renaming the temporary file fails.
441pub 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
511/// Checks that a committed schema exactly matches deterministic serialization of the document.
512///
513/// A missing committed file is reported as schema drift, while other read failures are reported as
514/// I/O errors.
515///
516/// # Errors
517///
518/// Returns a [`SchemaError`] when the document cannot be serialized, when the
519/// committed file exists but cannot be read, or, as drift, when the file is
520/// missing or differs from the generated schema.
521pub 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/// Asserts that a committed schema exactly matches deterministic serialization of the document.
541///
542/// # Panics
543///
544/// Panics with a drift summary or serialization/I/O error. Use [`check_no_drift`] when the caller
545/// should handle the error.
546#[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
557/// Checks that every property and parameter name in a document is camelCase.
558///
559/// See [`find_naming_violations`] for what the check covers. `exemptions` names standard-defined
560/// fields, such as OAuth 2.0 `access_token`, that keep their external spelling.
561///
562/// # Errors
563///
564/// Returns a [`SchemaError`] when the document cannot be serialized, or listing
565/// every non-camelCase property or parameter name not in `exemptions`.
566pub 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/// Asserts that every property and parameter name in a document is camelCase.
579///
580/// # Panics
581///
582/// Panics with one line per violation, or with a serialization error. Use
583/// [`check_camel_case_names`] when the caller should handle the error.
584#[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// Compiles the README's examples so they cannot drift from the API.
840#[doc = include_str!("../README.md")]
841#[cfg(doctest)]
842struct ReadmeDoctests;