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        f.write_str(self.path().as_str())
138    }
139}
140
141/// A versioned API spec filename.
142///
143/// Versioned APIs can have multiple versions coexisting. The filename includes
144/// the version and a content hash: `{ident}/{ident}-{version}-{hash}.json` (or
145/// `.json.gitstub` for Git stub storage).
146#[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq)]
147pub struct VersionedApiSpecFileName {
148    ident: ApiIdent,
149    version: semver::Version,
150    hash: String,
151    kind: VersionedApiSpecKind,
152}
153
154impl VersionedApiSpecFileName {
155    /// Creates a new versioned API spec filename (JSON format).
156    pub fn new(
157        ident: ApiIdent,
158        version: semver::Version,
159        hash: String,
160    ) -> Self {
161        Self { ident, version, hash, kind: VersionedApiSpecKind::Json }
162    }
163
164    /// Creates a new versioned API spec filename (Git stub format).
165    pub fn new_git_stub(
166        ident: ApiIdent,
167        version: semver::Version,
168        hash: String,
169    ) -> Self {
170        Self { ident, version, hash, kind: VersionedApiSpecKind::GitStub }
171    }
172
173    /// Returns the API identifier.
174    pub fn ident(&self) -> &ApiIdent {
175        &self.ident
176    }
177
178    /// Returns the version.
179    pub fn version(&self) -> &semver::Version {
180        &self.version
181    }
182
183    /// Returns the hash.
184    pub fn hash(&self) -> &str {
185        &self.hash
186    }
187
188    /// Returns the storage kind (JSON or Git stub).
189    pub fn kind(&self) -> VersionedApiSpecKind {
190        self.kind
191    }
192
193    /// Returns true if this is a Git stub.
194    pub fn is_git_stub(&self) -> bool {
195        self.kind == VersionedApiSpecKind::GitStub
196    }
197
198    /// Returns the path of this file relative to the root of the OpenAPI
199    /// documents.
200    pub fn path(&self) -> Utf8PathBuf {
201        Utf8PathBuf::from_iter([self.ident.deref().clone(), self.basename()])
202    }
203
204    /// Returns the base name of this file path.
205    pub fn basename(&self) -> String {
206        match self.kind {
207            VersionedApiSpecKind::Json => {
208                format!("{}-{}-{}.json", self.ident, self.version, self.hash)
209            }
210            VersionedApiSpecKind::GitStub => {
211                format!(
212                    "{}-{}-{}.json.gitstub",
213                    self.ident, self.version, self.hash
214                )
215            }
216        }
217    }
218
219    /// Converts this filename to its JSON equivalent.
220    ///
221    /// If already JSON, returns a clone of self.
222    pub fn to_json(&self) -> Self {
223        Self {
224            ident: self.ident.clone(),
225            version: self.version.clone(),
226            hash: self.hash.clone(),
227            kind: VersionedApiSpecKind::Json,
228        }
229    }
230
231    /// Converts this filename to its Git stub equivalent.
232    ///
233    /// If already a Git stub, returns a clone of self.
234    pub fn to_git_stub(&self) -> Self {
235        Self {
236            ident: self.ident.clone(),
237            version: self.version.clone(),
238            hash: self.hash.clone(),
239            kind: VersionedApiSpecKind::GitStub,
240        }
241    }
242
243    /// Returns the basename as a Git stubname.
244    ///
245    /// - If already a Git stub, returns `basename()` directly.
246    /// - If JSON, returns `basename() + ".gitstub"`.
247    pub fn git_stub_basename(&self) -> String {
248        match self.kind {
249            VersionedApiSpecKind::GitStub => self.basename(),
250            VersionedApiSpecKind::Json => {
251                format!("{}.gitstub", self.basename())
252            }
253        }
254    }
255
256    /// Returns the basename as a JSON filename.
257    ///
258    /// - If already JSON, returns `basename()` directly.
259    /// - If Git stub, returns the basename without `.gitstub`.
260    pub fn json_basename(&self) -> String {
261        match self.kind {
262            VersionedApiSpecKind::Json => self.basename(),
263            VersionedApiSpecKind::GitStub => {
264                format!("{}-{}-{}.json", self.ident, self.version, self.hash)
265            }
266        }
267    }
268}
269
270impl fmt::Display for VersionedApiSpecFileName {
271    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
272        f.write_str(self.path().as_str())
273    }
274}
275
276/// Describes how a versioned API spec file is stored.
277#[derive(Clone, Copy, Debug, Ord, PartialOrd, Eq, PartialEq)]
278pub enum VersionedApiSpecKind {
279    /// The spec is stored as a JSON file containing the full OpenAPI document.
280    Json,
281    /// The spec 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 ApiSpecFileName {
293    /// A lockstep API: single OpenAPI document, no versioning.
294    Lockstep(LockstepApiSpecFileName),
295    /// A versioned API: multiple versions can coexist.
296    Versioned(VersionedApiSpecFileName),
297}
298
299impl fmt::Display for ApiSpecFileName {
300    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
301        f.write_str(self.path().as_str())
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 == self.versioned_api_latest_symlink()
503    }
504}