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) -> &ApiSpecFileName {
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) -> &ApiSpecFileName;
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 spec 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 LockstepApiSpecFileName {
109    ident: ApiIdent,
110}
111
112impl LockstepApiSpecFileName {
113    /// Creates a new lockstep API spec 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 LockstepApiSpecFileName {
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 spec 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 VersionedApiSpecFileName {
149    ident: ApiIdent,
150    version: semver::Version,
151    hash: String,
152    kind: VersionedApiSpecKind,
153}
154
155impl VersionedApiSpecFileName {
156    /// Creates a new versioned API spec 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: VersionedApiSpecKind::Json }
163    }
164
165    /// Creates a new versioned API spec 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: VersionedApiSpecKind::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) -> VersionedApiSpecKind {
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 == VersionedApiSpecKind::GitStub
197    }
198
199    /// Returns the path of this file relative to the root of the OpenAPI
200    /// documents.
201    pub fn path(&self) -> Utf8PathBuf {
202        Utf8PathBuf::from_iter([self.ident.as_str(), &self.basename()])
203    }
204
205    /// Returns the base name of this file path.
206    pub fn basename(&self) -> String {
207        self.basename_for_kind(self.kind)
208    }
209
210    /// Returns the base name for a specific storage kind.
211    fn basename_for_kind(&self, kind: VersionedApiSpecKind) -> String {
212        match kind {
213            VersionedApiSpecKind::Json => {
214                format!("{}-{}-{}.json", self.ident, self.version, self.hash)
215            }
216            VersionedApiSpecKind::GitStub => {
217                format!(
218                    "{}-{}-{}.json.gitstub",
219                    self.ident, self.version, self.hash
220                )
221            }
222        }
223    }
224
225    /// Returns a copy of this filename with the given storage kind.
226    fn with_kind(&self, kind: VersionedApiSpecKind) -> Self {
227        Self {
228            ident: self.ident.clone(),
229            version: self.version.clone(),
230            hash: self.hash.clone(),
231            kind,
232        }
233    }
234
235    /// Converts this filename to its JSON equivalent.
236    ///
237    /// If already JSON, returns a clone of self.
238    pub fn to_json(&self) -> Self {
239        self.with_kind(VersionedApiSpecKind::Json)
240    }
241
242    /// Converts this filename to its Git stub equivalent.
243    ///
244    /// If already a Git stub, returns a clone of self.
245    pub fn to_git_stub(&self) -> Self {
246        self.with_kind(VersionedApiSpecKind::GitStub)
247    }
248
249    /// Returns the basename as a Git stubname.
250    ///
251    /// - If already a Git stub, returns `basename()` directly.
252    /// - If JSON, returns `basename() + ".gitstub"`.
253    pub fn git_stub_basename(&self) -> String {
254        self.basename_for_kind(VersionedApiSpecKind::GitStub)
255    }
256
257    /// Returns the basename as a JSON filename.
258    ///
259    /// - If already JSON, returns `basename()` directly.
260    /// - If Git stub, returns the basename without `.gitstub`.
261    pub fn json_basename(&self) -> String {
262        self.basename_for_kind(VersionedApiSpecKind::Json)
263    }
264}
265
266impl fmt::Display for VersionedApiSpecFileName {
267    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
268        // path = "{ident}/{basename}".
269        write!(f, "{}/{}", self.ident, self.basename())
270    }
271}
272
273/// Describes how a versioned API spec file is stored.
274#[derive(Clone, Copy, Debug, Ord, PartialOrd, Eq, PartialEq)]
275pub enum VersionedApiSpecKind {
276    /// The spec is stored as a JSON file containing the full OpenAPI document.
277    Json,
278    /// The spec is stored as a Git stub.
279    ///
280    /// Instead of storing the full JSON content, a `.gitstub` file contains a
281    /// reference in the format `commit:path` that can be used to retrieve the
282    /// content via `git show`.
283    GitStub,
284}
285
286/// Describes the path to an OpenAPI document file, relative to some root where
287/// similar documents are found.
288#[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq)]
289pub enum ApiSpecFileName {
290    /// A lockstep API: single OpenAPI document, no versioning.
291    Lockstep(LockstepApiSpecFileName),
292    /// A versioned API: multiple versions can coexist.
293    Versioned(VersionedApiSpecFileName),
294}
295
296impl fmt::Display for ApiSpecFileName {
297    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
298        match self {
299            ApiSpecFileName::Lockstep(l) => fmt::Display::fmt(l, f),
300            ApiSpecFileName::Versioned(v) => fmt::Display::fmt(v, f),
301        }
302    }
303}
304
305impl ApiSpecFileName {
306    /// Returns the API identifier.
307    pub fn ident(&self) -> &ApiIdent {
308        match self {
309            ApiSpecFileName::Lockstep(l) => l.ident(),
310            ApiSpecFileName::Versioned(v) => v.ident(),
311        }
312    }
313
314    /// Returns the path of this file relative to the root of the OpenAPI
315    /// documents.
316    pub fn path(&self) -> Utf8PathBuf {
317        match self {
318            ApiSpecFileName::Lockstep(l) => l.path(),
319            ApiSpecFileName::Versioned(v) => v.path(),
320        }
321    }
322
323    /// Returns the base name of this file path.
324    pub fn basename(&self) -> String {
325        match self {
326            ApiSpecFileName::Lockstep(l) => l.basename(),
327            ApiSpecFileName::Versioned(v) => v.basename(),
328        }
329    }
330
331    /// For versioned APIs, returns the version part of the filename.
332    pub fn version(&self) -> Option<&semver::Version> {
333        match self {
334            ApiSpecFileName::Lockstep(_) => None,
335            ApiSpecFileName::Versioned(v) => Some(v.version()),
336        }
337    }
338
339    /// For versioned APIs, returns the hash part of the filename.
340    pub fn hash(&self) -> Option<&str> {
341        match self {
342            ApiSpecFileName::Lockstep(_) => None,
343            ApiSpecFileName::Versioned(v) => Some(v.hash()),
344        }
345    }
346
347    /// Returns true if this is a Git stub.
348    pub fn is_git_stub(&self) -> bool {
349        match self {
350            ApiSpecFileName::Lockstep(_) => false,
351            ApiSpecFileName::Versioned(v) => v.is_git_stub(),
352        }
353    }
354
355    /// For versioned APIs, returns the kind of storage.
356    pub fn versioned_kind(&self) -> Option<VersionedApiSpecKind> {
357        match self {
358            ApiSpecFileName::Lockstep(_) => None,
359            ApiSpecFileName::Versioned(v) => Some(v.kind()),
360        }
361    }
362
363    /// Converts a Git stubname to its JSON equivalent.
364    ///
365    /// For non-Git stubs, returns a clone of self.
366    pub fn to_json_filename(&self) -> ApiSpecFileName {
367        match self {
368            ApiSpecFileName::Lockstep(_) => self.clone(),
369            ApiSpecFileName::Versioned(v) => {
370                ApiSpecFileName::Versioned(v.to_json())
371            }
372        }
373    }
374
375    /// Converts a JSON filename to its Git stub equivalent.
376    ///
377    /// For Git stubs, returns a clone of self.
378    /// For lockstep files, returns a clone of self (lockstep files are not
379    /// converted to Git stubs).
380    pub fn to_git_stub_filename(&self) -> ApiSpecFileName {
381        match self {
382            ApiSpecFileName::Lockstep(_) => self.clone(),
383            ApiSpecFileName::Versioned(v) => {
384                ApiSpecFileName::Versioned(v.to_git_stub())
385            }
386        }
387    }
388
389    /// Returns the basename for this file as a Git stub.
390    ///
391    /// - If this is already a Git stub, returns `basename()` directly.
392    /// - If this is a versioned JSON file, returns `basename() + ".gitstub"`.
393    /// - For lockstep, returns `basename()` (lockstep files are not converted
394    ///   to Git stubs).
395    pub fn git_stub_basename(&self) -> String {
396        match self {
397            ApiSpecFileName::Lockstep(l) => l.basename(),
398            ApiSpecFileName::Versioned(v) => v.git_stub_basename(),
399        }
400    }
401
402    /// Returns the basename for this file as a JSON file.
403    ///
404    /// - If this is a Git stub, returns the basename without the `.gitstub`
405    ///   suffix.
406    /// - Otherwise, returns `basename()` directly.
407    pub fn json_basename(&self) -> String {
408        match self {
409            ApiSpecFileName::Lockstep(l) => l.basename(),
410            ApiSpecFileName::Versioned(v) => v.json_basename(),
411        }
412    }
413
414    /// Returns a reference to the inner `VersionedApiSpecFileName` if this is
415    /// a versioned API, or `None` if this is a lockstep API.
416    pub fn as_versioned(&self) -> Option<&VersionedApiSpecFileName> {
417        match self {
418            ApiSpecFileName::Lockstep(_) => None,
419            ApiSpecFileName::Versioned(v) => Some(v),
420        }
421    }
422
423    /// Consumes `self` and returns the inner `VersionedApiSpecFileName` if
424    /// this is a versioned API, or `None` if this is a lockstep API.
425    pub fn into_versioned(self) -> Option<VersionedApiSpecFileName> {
426        match self {
427            ApiSpecFileName::Lockstep(_) => None,
428            ApiSpecFileName::Versioned(v) => Some(v),
429        }
430    }
431
432    /// Returns a reference to the inner `LockstepApiSpecFileName` if this is
433    /// a lockstep API, or `None` if this is a versioned API.
434    pub fn as_lockstep(&self) -> Option<&LockstepApiSpecFileName> {
435        match self {
436            ApiSpecFileName::Lockstep(l) => Some(l),
437            ApiSpecFileName::Versioned(_) => None,
438        }
439    }
440
441    /// Consumes `self` and returns the inner `LockstepApiSpecFileName` if
442    /// this is a lockstep API, or `None` if this is a versioned API.
443    pub fn into_lockstep(self) -> Option<LockstepApiSpecFileName> {
444        match self {
445            ApiSpecFileName::Lockstep(l) => Some(l),
446            ApiSpecFileName::Versioned(_) => None,
447        }
448    }
449}
450
451impl From<LockstepApiSpecFileName> for ApiSpecFileName {
452    fn from(l: LockstepApiSpecFileName) -> Self {
453        ApiSpecFileName::Lockstep(l)
454    }
455}
456
457impl From<VersionedApiSpecFileName> for ApiSpecFileName {
458    fn from(v: VersionedApiSpecFileName) -> Self {
459        ApiSpecFileName::Versioned(v)
460    }
461}
462
463/// Newtype for API identifiers
464#[derive(Clone, Ord, PartialOrd, Eq, PartialEq)]
465pub struct ApiIdent(String);
466
467impl fmt::Debug for ApiIdent {
468    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
469        self.0.fmt(f)
470    }
471}
472
473impl Deref for ApiIdent {
474    type Target = String;
475
476    fn deref(&self) -> &Self::Target {
477        &self.0
478    }
479}
480
481impl fmt::Display for ApiIdent {
482    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
483        self.0.fmt(f)
484    }
485}
486
487impl<S: Into<String>> From<S> for ApiIdent {
488    fn from(value: S) -> Self {
489        Self(value.into())
490    }
491}
492
493impl ApiIdent {
494    /// Given an API identifier, return the basename of its "latest" symlink
495    pub fn versioned_api_latest_symlink(&self) -> String {
496        format!("{self}-latest.json")
497    }
498
499    /// Given an API identifier and a file name, determine if we're looking at
500    /// this API's "latest" symlink.
501    pub fn versioned_api_is_latest_symlink(&self, base_name: &str) -> bool {
502        base_name
503            .strip_prefix(self.0.as_str())
504            .is_some_and(|rest| rest == "-latest.json")
505    }
506}