Skip to main content

dropshot_api_manager_types/
validation.rs

1// Copyright 2026 Oxide Computer Company
2
3use crate::{ManagedApiMetadata, Versions};
4use camino::Utf8PathBuf;
5use std::{fmt, ops::Deref};
6
7/// Context for validation of OpenAPI documents.
8pub struct ValidationContext<'a> {
9    backend: &'a mut dyn ValidationBackend,
10}
11
12impl<'a> ValidationContext<'a> {
13    /// Not part of the public API -- only called by the OpenAPI manager.
14    #[doc(hidden)]
15    pub fn new(backend: &'a mut dyn ValidationBackend) -> Self {
16        Self { backend }
17    }
18
19    /// Retrieves the identifier of the API being validated.
20    ///
21    /// This identifier is set via the OpenAPI manager's `ManagedApiConfig`
22    /// type.
23    pub fn ident(&self) -> &ApiIdent {
24        self.backend.ident()
25    }
26
27    /// Returns a descriptor for the API's file name.
28    ///
29    /// The file name can be used to identify the version of the API being
30    /// validated.
31    pub fn file_name(&self) -> &ApiDocFileName {
32        self.backend.file_name()
33    }
34
35    /// Returns true if this is the latest version of a versioned API, or if the
36    /// API is lockstep.
37    ///
38    /// This is particularly useful for extra files which might not themselves
39    /// be versioned. In that case, you may wish to only generate the extra file
40    /// for the latest version.
41    pub fn is_latest(&self) -> bool {
42        self.backend.is_latest()
43    }
44
45    /// Returns whether this version is blessed, or None if this is not a
46    /// versioned API.
47    pub fn is_blessed(&self) -> Option<bool> {
48        self.backend.is_blessed()
49    }
50
51    /// Retrieves the versioning strategy for this API.
52    pub fn versions(&self) -> &Versions {
53        self.backend.versions()
54    }
55
56    /// Retrieves the title of the API being validated.
57    pub fn title(&self) -> &str {
58        self.backend.title()
59    }
60
61    /// Retrieves optional metadata for the API being validated.
62    pub fn metadata(&self) -> &ManagedApiMetadata {
63        self.backend.metadata()
64    }
65
66    /// Reports a validation error.
67    pub fn report_error(&mut self, error: anyhow::Error) {
68        self.backend.report_error(error);
69    }
70
71    /// Records that the file has the given contents.
72    ///
73    /// In check mode, if the files differ, an error is logged.
74    ///
75    /// In generate mode, the file is overwritten with the given contents.
76    ///
77    /// The path is treated as relative to the root of the repository.
78    pub fn record_file_contents(
79        &mut self,
80        path: impl Into<Utf8PathBuf>,
81        contents: Vec<u8>,
82    ) {
83        self.backend.record_file_contents(path.into(), contents);
84    }
85}
86
87/// The backend for validation.
88///
89/// Not part of the public API -- only implemented by the OpenAPI manager.
90#[doc(hidden)]
91pub trait ValidationBackend {
92    fn ident(&self) -> &ApiIdent;
93    fn file_name(&self) -> &ApiDocFileName;
94    fn versions(&self) -> &Versions;
95    fn is_latest(&self) -> bool;
96    fn is_blessed(&self) -> Option<bool>;
97    fn title(&self) -> &str;
98    fn metadata(&self) -> &ManagedApiMetadata;
99    fn report_error(&mut self, error: anyhow::Error);
100    fn record_file_contents(&mut self, path: Utf8PathBuf, contents: Vec<u8>);
101}
102
103/// A lockstep API document filename.
104///
105/// Lockstep APIs have a single OpenAPI document with no versioning. The
106/// filename is simply `{ident}.json`.
107#[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq)]
108pub struct LockstepApiDocFileName {
109    ident: ApiIdent,
110}
111
112impl LockstepApiDocFileName {
113    /// Creates a new lockstep API document filename.
114    pub fn new(ident: ApiIdent) -> Self {
115        Self { ident }
116    }
117
118    /// Returns the API identifier.
119    pub fn ident(&self) -> &ApiIdent {
120        &self.ident
121    }
122
123    /// Returns the path of this file relative to the root of the OpenAPI
124    /// documents.
125    pub fn path(&self) -> Utf8PathBuf {
126        Utf8PathBuf::from(self.basename())
127    }
128
129    /// Returns the base name of this file path.
130    pub fn basename(&self) -> String {
131        format!("{}.json", self.ident)
132    }
133}
134
135impl fmt::Display for LockstepApiDocFileName {
136    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137        // For lockstep files, path == basename (no directory prefix).
138        f.write_str(&self.basename())
139    }
140}
141
142/// A versioned API document filename.
143///
144/// Versioned APIs can have multiple versions coexisting. The filename includes
145/// the version and a content hash: `{ident}/{ident}-{version}-{hash}.json` (or
146/// `.json.gitstub` for Git stub storage).
147#[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq)]
148pub struct VersionedApiDocFileName {
149    ident: ApiIdent,
150    version: semver::Version,
151    hash: String,
152    kind: VersionedApiDocKind,
153}
154
155impl VersionedApiDocFileName {
156    /// Creates a new versioned API document filename (JSON format).
157    pub fn new(
158        ident: ApiIdent,
159        version: semver::Version,
160        hash: String,
161    ) -> Self {
162        Self { ident, version, hash, kind: VersionedApiDocKind::Json }
163    }
164
165    /// Creates a new versioned API document filename (Git stub format).
166    pub fn new_git_stub(
167        ident: ApiIdent,
168        version: semver::Version,
169        hash: String,
170    ) -> Self {
171        Self { ident, version, hash, kind: VersionedApiDocKind::GitStub }
172    }
173
174    /// Returns the API identifier.
175    pub fn ident(&self) -> &ApiIdent {
176        &self.ident
177    }
178
179    /// Returns the version.
180    pub fn version(&self) -> &semver::Version {
181        &self.version
182    }
183
184    /// Returns the hash.
185    pub fn hash(&self) -> &str {
186        &self.hash
187    }
188
189    /// Returns the storage kind (JSON or Git stub).
190    pub fn kind(&self) -> VersionedApiDocKind {
191        self.kind
192    }
193
194    /// Returns true if this is a Git stub.
195    pub fn is_git_stub(&self) -> bool {
196        self.kind == VersionedApiDocKind::GitStub
197    }
198
199    /// Returns the path of this file relative to the root of the OpenAPI
200    /// documents.
201    ///
202    /// The path is always joined with a forward slash, including on Windows.
203    pub fn path(&self) -> Utf8PathBuf {
204        Utf8PathBuf::from(format!("{}/{}", self.ident, self.basename()))
205    }
206
207    /// Returns the base name of this file path.
208    pub fn basename(&self) -> String {
209        self.basename_for_kind(self.kind)
210    }
211
212    /// Returns the base name for a specific storage kind.
213    fn basename_for_kind(&self, kind: VersionedApiDocKind) -> String {
214        match kind {
215            VersionedApiDocKind::Json => {
216                format!("{}-{}-{}.json", self.ident, self.version, self.hash)
217            }
218            VersionedApiDocKind::GitStub => {
219                format!(
220                    "{}-{}-{}.json.gitstub",
221                    self.ident, self.version, self.hash
222                )
223            }
224        }
225    }
226
227    /// Returns a copy of this filename with the given storage kind.
228    fn with_kind(&self, kind: VersionedApiDocKind) -> Self {
229        Self {
230            ident: self.ident.clone(),
231            version: self.version.clone(),
232            hash: self.hash.clone(),
233            kind,
234        }
235    }
236
237    /// Converts this filename to its JSON equivalent.
238    ///
239    /// If already JSON, returns a clone of self.
240    pub fn to_json(&self) -> Self {
241        self.with_kind(VersionedApiDocKind::Json)
242    }
243
244    /// Converts this filename to its Git stub equivalent.
245    ///
246    /// If already a Git stub, returns a clone of self.
247    pub fn to_git_stub(&self) -> Self {
248        self.with_kind(VersionedApiDocKind::GitStub)
249    }
250
251    /// Returns the basename as a Git stubname.
252    ///
253    /// - If already a Git stub, returns `basename()` directly.
254    /// - If JSON, returns `basename() + ".gitstub"`.
255    pub fn git_stub_basename(&self) -> String {
256        self.basename_for_kind(VersionedApiDocKind::GitStub)
257    }
258
259    /// Returns the basename as a JSON filename.
260    ///
261    /// - If already JSON, returns `basename()` directly.
262    /// - If Git stub, returns the basename without `.gitstub`.
263    pub fn json_basename(&self) -> String {
264        self.basename_for_kind(VersionedApiDocKind::Json)
265    }
266}
267
268impl fmt::Display for VersionedApiDocFileName {
269    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
270        // path = "{ident}/{basename}".
271        write!(f, "{}/{}", self.ident, self.basename())
272    }
273}
274
275/// Describes how a versioned API document file is stored.
276#[derive(Clone, Copy, Debug, Ord, PartialOrd, Eq, PartialEq)]
277pub enum VersionedApiDocKind {
278    /// The document is stored as a JSON file containing the full OpenAPI
279    /// document.
280    Json,
281    /// The document is stored as a Git stub.
282    ///
283    /// Instead of storing the full JSON content, a `.gitstub` file contains a
284    /// reference in the format `commit:path` that can be used to retrieve the
285    /// content via `git show`.
286    GitStub,
287}
288
289/// Describes the path to an OpenAPI document file, relative to some root where
290/// similar documents are found.
291#[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq)]
292pub enum ApiDocFileName {
293    /// A lockstep API: single OpenAPI document, no versioning.
294    Lockstep(LockstepApiDocFileName),
295    /// A versioned API: multiple versions can coexist.
296    Versioned(VersionedApiDocFileName),
297}
298
299impl fmt::Display for ApiDocFileName {
300    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
301        match self {
302            ApiDocFileName::Lockstep(l) => fmt::Display::fmt(l, f),
303            ApiDocFileName::Versioned(v) => fmt::Display::fmt(v, f),
304        }
305    }
306}
307
308impl ApiDocFileName {
309    /// Returns the API identifier.
310    pub fn ident(&self) -> &ApiIdent {
311        match self {
312            ApiDocFileName::Lockstep(l) => l.ident(),
313            ApiDocFileName::Versioned(v) => v.ident(),
314        }
315    }
316
317    /// Returns the path of this file relative to the root of the OpenAPI
318    /// documents.
319    pub fn path(&self) -> Utf8PathBuf {
320        match self {
321            ApiDocFileName::Lockstep(l) => l.path(),
322            ApiDocFileName::Versioned(v) => v.path(),
323        }
324    }
325
326    /// Returns the base name of this file path.
327    pub fn basename(&self) -> String {
328        match self {
329            ApiDocFileName::Lockstep(l) => l.basename(),
330            ApiDocFileName::Versioned(v) => v.basename(),
331        }
332    }
333
334    /// For versioned APIs, returns the version part of the filename.
335    pub fn version(&self) -> Option<&semver::Version> {
336        match self {
337            ApiDocFileName::Lockstep(_) => None,
338            ApiDocFileName::Versioned(v) => Some(v.version()),
339        }
340    }
341
342    /// For versioned APIs, returns the hash part of the filename.
343    pub fn hash(&self) -> Option<&str> {
344        match self {
345            ApiDocFileName::Lockstep(_) => None,
346            ApiDocFileName::Versioned(v) => Some(v.hash()),
347        }
348    }
349
350    /// Returns true if this is a Git stub.
351    pub fn is_git_stub(&self) -> bool {
352        match self {
353            ApiDocFileName::Lockstep(_) => false,
354            ApiDocFileName::Versioned(v) => v.is_git_stub(),
355        }
356    }
357
358    /// For versioned APIs, returns the kind of storage.
359    pub fn versioned_kind(&self) -> Option<VersionedApiDocKind> {
360        match self {
361            ApiDocFileName::Lockstep(_) => None,
362            ApiDocFileName::Versioned(v) => Some(v.kind()),
363        }
364    }
365
366    /// Converts a Git stubname to its JSON equivalent.
367    ///
368    /// For non-Git stubs, returns a clone of self.
369    pub fn to_json_filename(&self) -> ApiDocFileName {
370        match self {
371            ApiDocFileName::Lockstep(_) => self.clone(),
372            ApiDocFileName::Versioned(v) => {
373                ApiDocFileName::Versioned(v.to_json())
374            }
375        }
376    }
377
378    /// Converts a JSON filename to its Git stub equivalent.
379    ///
380    /// For Git stubs, returns a clone of self.
381    /// For lockstep files, returns a clone of self (lockstep files are not
382    /// converted to Git stubs).
383    pub fn to_git_stub_filename(&self) -> ApiDocFileName {
384        match self {
385            ApiDocFileName::Lockstep(_) => self.clone(),
386            ApiDocFileName::Versioned(v) => {
387                ApiDocFileName::Versioned(v.to_git_stub())
388            }
389        }
390    }
391
392    /// Returns the basename for this file as a Git stub.
393    ///
394    /// - If this is already a Git stub, returns `basename()` directly.
395    /// - If this is a versioned JSON file, returns `basename() + ".gitstub"`.
396    /// - For lockstep, returns `basename()` (lockstep files are not converted
397    ///   to Git stubs).
398    pub fn git_stub_basename(&self) -> String {
399        match self {
400            ApiDocFileName::Lockstep(l) => l.basename(),
401            ApiDocFileName::Versioned(v) => v.git_stub_basename(),
402        }
403    }
404
405    /// Returns the basename for this file as a JSON file.
406    ///
407    /// - If this is a Git stub, returns the basename without the `.gitstub`
408    ///   suffix.
409    /// - Otherwise, returns `basename()` directly.
410    pub fn json_basename(&self) -> String {
411        match self {
412            ApiDocFileName::Lockstep(l) => l.basename(),
413            ApiDocFileName::Versioned(v) => v.json_basename(),
414        }
415    }
416
417    /// Returns a reference to the inner `VersionedApiDocFileName` if this is
418    /// a versioned API, or `None` if this is a lockstep API.
419    pub fn as_versioned(&self) -> Option<&VersionedApiDocFileName> {
420        match self {
421            ApiDocFileName::Lockstep(_) => None,
422            ApiDocFileName::Versioned(v) => Some(v),
423        }
424    }
425
426    /// Consumes `self` and returns the inner `VersionedApiDocFileName` if
427    /// this is a versioned API, or `None` if this is a lockstep API.
428    pub fn into_versioned(self) -> Option<VersionedApiDocFileName> {
429        match self {
430            ApiDocFileName::Lockstep(_) => None,
431            ApiDocFileName::Versioned(v) => Some(v),
432        }
433    }
434
435    /// Returns a reference to the inner `LockstepApiDocFileName` if this is
436    /// a lockstep API, or `None` if this is a versioned API.
437    pub fn as_lockstep(&self) -> Option<&LockstepApiDocFileName> {
438        match self {
439            ApiDocFileName::Lockstep(l) => Some(l),
440            ApiDocFileName::Versioned(_) => None,
441        }
442    }
443
444    /// Consumes `self` and returns the inner `LockstepApiDocFileName` if
445    /// this is a lockstep API, or `None` if this is a versioned API.
446    pub fn into_lockstep(self) -> Option<LockstepApiDocFileName> {
447        match self {
448            ApiDocFileName::Lockstep(l) => Some(l),
449            ApiDocFileName::Versioned(_) => None,
450        }
451    }
452}
453
454impl From<LockstepApiDocFileName> for ApiDocFileName {
455    fn from(l: LockstepApiDocFileName) -> Self {
456        ApiDocFileName::Lockstep(l)
457    }
458}
459
460impl From<VersionedApiDocFileName> for ApiDocFileName {
461    fn from(v: VersionedApiDocFileName) -> Self {
462        ApiDocFileName::Versioned(v)
463    }
464}
465
466/// Newtype for API identifiers
467#[derive(Clone, Ord, PartialOrd, Eq, PartialEq)]
468pub struct ApiIdent(String);
469
470impl fmt::Debug for ApiIdent {
471    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
472        self.0.fmt(f)
473    }
474}
475
476impl Deref for ApiIdent {
477    type Target = String;
478
479    fn deref(&self) -> &Self::Target {
480        &self.0
481    }
482}
483
484impl fmt::Display for ApiIdent {
485    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
486        self.0.fmt(f)
487    }
488}
489
490impl<S: Into<String>> From<S> for ApiIdent {
491    fn from(value: S) -> Self {
492        Self(value.into())
493    }
494}
495
496impl ApiIdent {
497    /// Given an API identifier, return the basename of its "latest" symlink
498    pub fn versioned_api_latest_symlink(&self) -> String {
499        format!("{self}-latest.json")
500    }
501
502    /// Given an API identifier and a file name, determine if we're looking at
503    /// this API's "latest" symlink.
504    pub fn versioned_api_is_latest_symlink(&self, base_name: &str) -> bool {
505        base_name
506            .strip_prefix(self.0.as_str())
507            .is_some_and(|rest| rest == "-latest.json")
508    }
509}