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, offers opt-in
5//! bearer authentication metadata, and manages a deterministic committed schema.
6//!
7//! # Example
8//!
9//! ```rust
10//! use baukit_openapi::{ErrorEnvelope, OpenApiMetadata, serialize_schema};
11//! use utoipa::openapi::Server;
12//!
13//! #[derive(utoipa::OpenApi)]
14//! #[openapi(components(schemas(ErrorEnvelope)))]
15//! struct ApiDoc;
16//!
17//! let mut document = <ApiDoc as utoipa::OpenApi>::openapi();
18//! OpenApiMetadata::new("Orders API", "1.2.3", "The Orders service API")
19//!     .servers([Server::new("https://api.example.com")])
20//!     .apply_to(&mut document);
21//!
22//! let json = serialize_schema(&document)?;
23//! assert!(json.ends_with('\n'));
24//! # Ok::<(), baukit_openapi::SchemaError>(())
25//! ```
26
27#![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
43/// The component name used for Baukit's standard HTTP bearer JWT security scheme.
44pub const BEARER_AUTH_SCHEME: &str = "bearerAuth";
45
46/// Baukit document metadata and optional security conventions.
47///
48/// New metadata uses `/` as its server, allowing the same schema to describe the service at any
49/// deployment origin. Use [`OpenApiMetadata::servers`] when a product has explicit server URLs.
50/// Applying metadata preserves product-owned paths, schemas, contact information, license, and
51/// existing security schemes. Call [`OpenApiMetadata::bearer_auth`] to add
52/// Baukit's standard bearer JWT component; unauthenticated APIs omit it by default.
53#[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    /// Creates metadata from a service's display title, version, and safe public description.
64    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    /// Replaces the default relative server with the supplied server entries.
80    ///
81    /// Passing an empty iterator omits the explicit server list and restores OpenAPI's implicit
82    /// `/` server behavior.
83    #[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    /// Adds Baukit's standard HTTP bearer JWT security-scheme component.
93    ///
94    /// This only registers the reusable component named [`BEARER_AUTH_SCHEME`];
95    /// products still decide which operations or documents require it.
96    #[must_use]
97    pub const fn bearer_auth(mut self) -> Self {
98        self.bearer_auth = true;
99        self
100    }
101
102    /// Applies metadata, the server list, and any opted-in conventions to a document.
103    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/// The shared public error response envelope.
122///
123/// This type deliberately has no HTTP-framework dependency so runtime crates can serialize and
124/// reuse it directly.
125#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)]
126pub struct ErrorEnvelope {
127    /// The structured public error.
128    pub error: ErrorBody,
129}
130
131/// The error body nested inside [`ErrorEnvelope`].
132#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)]
133pub struct ErrorBody {
134    /// Stable, machine-readable error code.
135    pub code: String,
136    /// Safe user-facing or intentionally generic message.
137    pub message: String,
138    /// Request identifier shared with logs and support tooling.
139    pub request_id: String,
140    /// Structured product- or validation-specific error details.
141    pub details: BTreeMap<String, Value>,
142}
143
144/// An API timestamp with one JSON and OpenAPI representation: an RFC 3339
145/// string with OpenAPI `date-time` format.
146///
147/// Use this at DTO boundaries around a domain [`time::OffsetDateTime`]. The
148/// wrapper prevents `time`'s default non-human-readable nine-element tuple from
149/// leaking into JSON while keeping conversion to and from the domain type
150/// explicit and lossless.
151#[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    /// Wraps a domain timestamp for use in an API DTO.
159    pub const fn new(value: time::OffsetDateTime) -> Self {
160        Self(value)
161    }
162
163    /// Returns the wrapped domain timestamp.
164    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/// A fully typed success-response envelope.
200///
201/// Both `data` and `meta` remain concrete generic schema arguments. Products
202/// should define DTOs for them rather than substituting [`serde_json::Value`],
203/// so generated clients retain the complete response contract. A paginated
204/// response uses `Vec<ItemDto>` as `T` and a product-owned pagination metadata
205/// DTO as `M`.
206#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)]
207pub struct ResponseEnvelope<T, M> {
208    /// The typed response payload.
209    pub data: T,
210    /// Typed response metadata, such as request ID and pagination cursor.
211    pub meta: M,
212}
213
214impl<T, M> ResponseEnvelope<T, M> {
215    /// Creates a fully typed response envelope.
216    pub const fn new(data: T, meta: M) -> Self {
217        Self { data, meta }
218    }
219
220    /// Maps the payload without weakening the metadata type.
221    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/// An error produced while serializing, writing, or comparing an OpenAPI schema.
230///
231/// Its display text includes the affected path and, for drift, the first differing line together
232/// with the CI remediation instruction.
233#[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    /// Returns `true` when the committed schema differs from the generated document.
289    #[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
355/// Serializes an OpenAPI document as deterministically ordered pretty JSON with a trailing newline.
356///
357/// Object keys are recursively sorted after Utoipa serialization. This also stabilizes extension
358/// maps whose backing collection can otherwise have nondeterministic iteration order.
359pub 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
382/// Writes a deterministic schema atomically, creating parent directories when necessary.
383///
384/// The temporary file is created beside the destination so the final rename stays on the same
385/// filesystem.
386pub 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
456/// Checks that a committed schema exactly matches deterministic serialization of the document.
457///
458/// A missing committed file is reported as schema drift, while other read failures are reported as
459/// I/O errors.
460pub 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/// Asserts that a committed schema exactly matches deterministic serialization of the document.
480///
481/// # Panics
482///
483/// Panics with a drift summary or serialization/I/O error. Use [`check_no_drift`] when the caller
484/// should handle the error.
485#[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}
736
737// Compiles the README's examples so they cannot drift from the API.
738#[doc = include_str!("../README.md")]
739#[cfg(doctest)]
740struct ReadmeDoctests;