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;
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
60/// The component name used for Baukit's standard HTTP bearer JWT security scheme.
61pub const BEARER_AUTH_SCHEME: &str = "bearerAuth";
62
63/// Baukit document metadata and optional security conventions.
64///
65/// New metadata uses `/` as its server, allowing the same schema to describe the service at any
66/// deployment origin. Use [`OpenApiMetadata::servers`] when a product has explicit server URLs.
67/// Applying metadata preserves product-owned paths, schemas, contact information, license, and
68/// existing security schemes. Call [`OpenApiMetadata::bearer_auth`] to add
69/// Baukit's standard bearer JWT component; unauthenticated APIs omit it by default.
70#[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    /// Creates metadata from a service's display title, version, and safe public description.
81    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    /// Replaces the default relative server with the supplied server entries.
97    ///
98    /// Passing an empty iterator omits the explicit server list and restores OpenAPI's implicit
99    /// `/` server behavior.
100    #[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    /// Adds Baukit's standard HTTP bearer JWT security-scheme component.
110    ///
111    /// This only registers the reusable component named [`BEARER_AUTH_SCHEME`];
112    /// products still decide which operations or documents require it.
113    #[must_use]
114    pub const fn bearer_auth(mut self) -> Self {
115        self.bearer_auth = true;
116        self
117    }
118
119    /// Applies metadata, the server list, and any opted-in conventions to a document.
120    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/// The shared public error response envelope.
139///
140/// This type deliberately has no HTTP-framework dependency so runtime crates can serialize and
141/// reuse it directly.
142#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)]
143pub struct ErrorEnvelope {
144    /// The structured public error.
145    pub error: ErrorBody,
146}
147
148/// The error body nested inside [`ErrorEnvelope`].
149#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)]
150#[serde(rename_all = "camelCase")]
151pub struct ErrorBody {
152    /// Stable, machine-readable error code.
153    pub code: String,
154    /// Safe user-facing or intentionally generic message.
155    pub message: String,
156    /// Request identifier shared with logs and support tooling.
157    pub request_id: String,
158    /// Structured product- or validation-specific error details.
159    pub details: BTreeMap<String, Value>,
160}
161
162/// An API timestamp with one JSON and OpenAPI representation: an RFC 3339
163/// string with OpenAPI `date-time` format.
164///
165/// Use this at DTO boundaries around a domain [`time::OffsetDateTime`]. The
166/// wrapper prevents `time`'s default non-human-readable nine-element tuple from
167/// leaking into JSON while keeping conversion to and from the domain type
168/// explicit and lossless.
169#[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    /// Wraps a domain timestamp for use in an API DTO.
177    pub const fn new(value: time::OffsetDateTime) -> Self {
178        Self(value)
179    }
180
181    /// Returns the wrapped domain timestamp.
182    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/// A fully typed success-response envelope.
218///
219/// Both `data` and `meta` remain concrete generic schema arguments. Products
220/// should define DTOs for them rather than substituting [`serde_json::Value`],
221/// so generated clients retain the complete response contract. A paginated
222/// response uses `Vec<ItemDto>` as `T` and a product-owned pagination metadata
223/// DTO as `M`.
224#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)]
225pub struct ResponseEnvelope<T, M> {
226    /// The typed response payload.
227    pub data: T,
228    /// Typed response metadata, such as request ID and pagination cursor.
229    pub meta: M,
230}
231
232impl<T, M> ResponseEnvelope<T, M> {
233    /// Creates a fully typed response envelope.
234    pub const fn new(data: T, meta: M) -> Self {
235        Self { data, meta }
236    }
237
238    /// Maps the payload without weakening the metadata type.
239    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/// An error produced while serializing, writing, comparing, or naming-checking an OpenAPI schema.
248///
249/// Its display text includes the affected path and, for drift, the first differing line together
250/// with the CI remediation instruction. A naming failure lists every violation.
251#[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    /// Returns `true` when the committed schema differs from the generated document.
308    #[must_use]
309    pub fn is_drift(&self) -> bool {
310        matches!(self.kind, SchemaErrorKind::Drift { .. })
311    }
312
313    /// Returns the names that failed [`check_camel_case_names`], or an empty slice for any other
314    /// error.
315    #[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
394/// Serializes an OpenAPI document as deterministically ordered pretty JSON with a trailing newline.
395///
396/// Object keys are recursively sorted after Utoipa serialization. This also stabilizes extension
397/// maps whose backing collection can otherwise have nondeterministic iteration order.
398pub 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
421/// Writes a deterministic schema atomically, creating parent directories when necessary.
422///
423/// The temporary file is created beside the destination so the final rename stays on the same
424/// filesystem.
425pub 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
495/// Checks that a committed schema exactly matches deterministic serialization of the document.
496///
497/// A missing committed file is reported as schema drift, while other read failures are reported as
498/// I/O errors.
499pub 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/// Asserts that a committed schema exactly matches deterministic serialization of the document.
519///
520/// # Panics
521///
522/// Panics with a drift summary or serialization/I/O error. Use [`check_no_drift`] when the caller
523/// should handle the error.
524#[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
531/// Checks that every property and parameter name in a document is camelCase.
532///
533/// See [`find_naming_violations`] for what the check covers. `exemptions` names standard-defined
534/// fields, such as OAuth 2.0 `access_token`, that keep their external spelling.
535pub 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/// Asserts that every property and parameter name in a document is camelCase.
548///
549/// # Panics
550///
551/// Panics with one line per violation, or with a serialization error. Use
552/// [`check_camel_case_names`] when the caller should handle the error.
553#[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// Compiles the README's examples so they cannot drift from the API.
808#[doc = include_str!("../README.md")]
809#[cfg(doctest)]
810struct ReadmeDoctests;