Skip to main content

dropshot_api_manager/
apis.rs

1// Copyright 2026 Oxide Computer Company
2
3use crate::validation::DynValidationFn;
4use anyhow::{Context, bail};
5use dropshot::{ApiDescription, ApiDescriptionBuildErrors, StubContext};
6use dropshot_api_manager_types::{
7    ApiIdent, IterVersionsSemvers, ManagedApiMetadata, SupportedVersion,
8    ValidationContext, Versions,
9};
10use openapiv3::OpenAPI;
11use std::{
12    collections::{BTreeMap, BTreeSet},
13    fmt,
14};
15
16/// Describes an API managed by the Dropshot API manager.
17///
18/// Each API listed within a `ManagedApiConfig` forms a unit managed by the
19/// Dropshot API manager.
20#[derive(Clone, Debug)]
21pub struct ManagedApiConfig {
22    /// The API-specific part of the filename that's used for API descriptions
23    ///
24    /// This string is sometimes used as an identifier for developers.
25    pub ident: &'static str,
26
27    /// how this API is versioned
28    pub versions: Versions,
29
30    /// title of the API (goes into OpenAPI document)
31    pub title: &'static str,
32
33    /// metadata about the API
34    pub metadata: ManagedApiMetadata,
35
36    /// The API description function, typically a reference to
37    /// `stub_api_description`
38    ///
39    /// This is used to generate the OpenAPI document that matches the current
40    /// server implementation.
41    pub api_description:
42        fn() -> Result<ApiDescription<StubContext>, ApiDescriptionBuildErrors>,
43}
44
45/// Describes an API managed by the Dropshot API manager.
46///
47/// This type is typically created from a [`ManagedApiConfig`] and can be
48/// further configured using builder methods before being passed to
49/// [`ManagedApis::new`].
50pub struct ManagedApi {
51    /// The API-specific part of the filename that's used for API descriptions
52    ///
53    /// This string is sometimes used as an identifier for developers.
54    ident: ApiIdent,
55
56    /// how this API is versioned
57    versions: Versions,
58
59    /// title of the API (goes into OpenAPI document)
60    title: &'static str,
61
62    /// metadata about the API
63    metadata: ManagedApiMetadata,
64
65    /// The API description function, typically a reference to
66    /// `stub_api_description`
67    ///
68    /// This is used to generate the OpenAPI document that matches the current
69    /// server implementation.
70    api_description:
71        fn() -> Result<ApiDescription<StubContext>, ApiDescriptionBuildErrors>,
72
73    /// Extra validation to perform on the OpenAPI document, if any.
74    ///
75    /// For versioned APIs, extra validation is performed on *all* versions,
76    /// including blessed ones. You may want to skip performing validation on
77    /// blessed versions, though, because they're immutable. To do so, use
78    /// [`ValidationContext::is_blessed`].
79    extra_validation: Option<Box<DynValidationFn>>,
80
81    /// If true, allow trivial changes (doc updates, type renames) for the
82    /// latest blessed version without requiring version bumps.
83    ///
84    /// Default: false (bytewise check is performed for latest version).
85    allow_trivial_changes_for_latest: bool,
86
87    /// Per-API override for Git stub storage.
88    ///
89    /// - `None`: use the global setting from `ManagedApis`.
90    /// - `Some(true)`: enable Git stub storage for this API.
91    /// - `Some(false)`: disable Git stub storage for this API.
92    use_git_stub_storage: Option<bool>,
93}
94
95impl fmt::Debug for ManagedApi {
96    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97        let Self {
98            ident,
99            versions,
100            title,
101            metadata,
102            api_description: _,
103            extra_validation,
104            allow_trivial_changes_for_latest,
105            use_git_stub_storage,
106        } = self;
107
108        f.debug_struct("ManagedApi")
109            .field("ident", ident)
110            .field("versions", versions)
111            .field("title", title)
112            .field("metadata", metadata)
113            .field("api_description", &"...")
114            .field(
115                "extra_validation",
116                &extra_validation.as_ref().map(|_| "..."),
117            )
118            .field(
119                "allow_trivial_changes_for_latest",
120                allow_trivial_changes_for_latest,
121            )
122            .field("use_git_stub_storage", use_git_stub_storage)
123            .finish()
124    }
125}
126
127impl From<ManagedApiConfig> for ManagedApi {
128    fn from(value: ManagedApiConfig) -> Self {
129        let ManagedApiConfig {
130            ident,
131            versions,
132            title,
133            metadata,
134            api_description,
135        } = value;
136        ManagedApi {
137            ident: ApiIdent::from(ident.to_owned()),
138            versions,
139            title,
140            metadata,
141            api_description,
142            extra_validation: None,
143            allow_trivial_changes_for_latest: false,
144            use_git_stub_storage: None,
145        }
146    }
147}
148
149impl ManagedApi {
150    /// Returns the API identifier.
151    pub fn ident(&self) -> &ApiIdent {
152        &self.ident
153    }
154
155    /// Returns the API versions.
156    pub fn versions(&self) -> &Versions {
157        &self.versions
158    }
159
160    /// Returns the API title.
161    pub fn title(&self) -> &'static str {
162        self.title
163    }
164
165    /// Returns the API metadata.
166    pub fn metadata(&self) -> &ManagedApiMetadata {
167        &self.metadata
168    }
169
170    /// Returns true if the API is lockstep.
171    pub fn is_lockstep(&self) -> bool {
172        self.versions.is_lockstep()
173    }
174
175    /// Returns true if the API is versioned.
176    pub fn is_versioned(&self) -> bool {
177        self.versions.is_versioned()
178    }
179
180    /// Allows trivial changes (doc updates, type renames) for the latest
181    /// blessed version without requiring a version bump.
182    ///
183    /// By default, the latest blessed version requires bytewise equality
184    /// between blessed and generated documents. This prevents trivial changes
185    /// from accumulating invisibly. Calling this method allows semantic-only
186    /// checking for all versions, including the latest.
187    pub fn allow_trivial_changes_for_latest(mut self) -> Self {
188        self.allow_trivial_changes_for_latest = true;
189        self
190    }
191
192    /// Returns true if trivial changes are allowed for the latest version.
193    pub fn allows_trivial_changes_for_latest(&self) -> bool {
194        self.allow_trivial_changes_for_latest
195    }
196
197    /// Enables Git stub storage for this API, overriding the global setting.
198    ///
199    /// When enabled, non-latest blessed API versions are stored as `.gitstub`
200    /// files containing a Git stub instead of full JSON files.
201    pub fn with_git_stub_storage(mut self) -> Self {
202        self.use_git_stub_storage = Some(true);
203        self
204    }
205
206    /// Disables Git stub storage for this API, overriding the global setting.
207    pub fn disable_git_stub_storage(mut self) -> Self {
208        self.use_git_stub_storage = Some(false);
209        self
210    }
211
212    /// Returns the Git stub storage setting for this API.
213    ///
214    /// - `None`: use the global setting.
215    /// - `Some(true)`: Git stub storage is enabled for this API.
216    /// - `Some(false)`: Git stub storage is disabled for this API.
217    pub fn uses_git_stub_storage(&self) -> Option<bool> {
218        self.use_git_stub_storage
219    }
220
221    /// Sets extra validation to perform on the OpenAPI document.
222    ///
223    /// For versioned APIs, extra validation is performed on *all* versions,
224    /// including blessed ones. You may want to skip performing validation on
225    /// blessed versions, though, because they're immutable. To do so, use
226    /// [`ValidationContext::is_blessed`].
227    pub fn with_extra_validation<F>(mut self, f: F) -> Self
228    where
229        F: Fn(&OpenAPI, ValidationContext<'_>) + Send + Sync + 'static,
230    {
231        self.extra_validation = Some(Box::new(f));
232        self
233    }
234
235    pub(crate) fn iter_versioned_versions(
236        &self,
237    ) -> Option<impl Iterator<Item = &SupportedVersion> + '_> {
238        self.versions.iter_versioned_versions()
239    }
240
241    pub(crate) fn iter_versions_semver(&self) -> IterVersionsSemvers<'_> {
242        self.versions.iter_versions_semvers()
243    }
244
245    pub(crate) fn generate_openapi_doc(
246        &self,
247        version: &semver::Version,
248    ) -> anyhow::Result<OpenAPI> {
249        // It's a bit weird to first convert to bytes and then back to OpenAPI,
250        // but this is the easiest way to do so (currently, Dropshot doesn't
251        // return the OpenAPI type directly). It is also consistent with the
252        // other code paths.
253        let contents = self.generate_spec_bytes(version)?;
254        serde_json::from_slice(&contents)
255            .context("generated document is not valid OpenAPI")
256    }
257
258    pub(crate) fn generate_spec_bytes(
259        &self,
260        version: &semver::Version,
261    ) -> anyhow::Result<Vec<u8>> {
262        let description = (self.api_description)().map_err(|error| {
263            // ApiDescriptionBuildError is actually a list of errors so it
264            // doesn't implement std::error::Error itself. Its Display
265            // impl formats the errors appropriately.
266            anyhow::anyhow!("{}", error)
267        })?;
268        let mut openapi_def = description.openapi(self.title, version.clone());
269        if let Some(description) = self.metadata.description {
270            openapi_def.description(description);
271        }
272        if let Some(contact_url) = self.metadata.contact_url {
273            openapi_def.contact_url(contact_url);
274        }
275        if let Some(contact_email) = self.metadata.contact_email {
276            openapi_def.contact_email(contact_email);
277        }
278
279        // Use write because it's the most reliable way to get the canonical
280        // JSON order. The `json` method returns a serde_json::Value which may
281        // or may not have preserve_order enabled.
282        let mut contents = Vec::new();
283        openapi_def.write(&mut contents)?;
284        Ok(contents)
285    }
286
287    pub(crate) fn extra_validation(
288        &self,
289        openapi: &OpenAPI,
290        validation_context: ValidationContext<'_>,
291    ) {
292        if let Some(extra_validation) = &self.extra_validation {
293            extra_validation(openapi, validation_context);
294        }
295    }
296}
297
298/// Describes the Rust-defined configuration for all of the APIs managed by this
299/// tool.
300///
301/// This is repo-specific state that's passed into the OpenAPI manager.
302pub struct ManagedApis {
303    apis: BTreeMap<ApiIdent, ManagedApi>,
304    unknown_apis: BTreeSet<ApiIdent>,
305    validation: Option<Box<DynValidationFn>>,
306
307    /// If true, store non-latest blessed API versions as Git stubs instead
308    /// of full JSON files. This saves disk space but requires git access to
309    /// read the contents.
310    ///
311    /// The default is false.
312    use_git_stub_storage: bool,
313}
314
315impl fmt::Debug for ManagedApis {
316    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
317        let Self { apis, unknown_apis, validation, use_git_stub_storage } =
318            self;
319
320        f.debug_struct("ManagedApis")
321            .field("apis", apis)
322            .field("unknown_apis", unknown_apis)
323            .field("validation", &validation.as_ref().map(|_| "..."))
324            .field("use_git_stub_storage", use_git_stub_storage)
325            .finish()
326    }
327}
328
329impl ManagedApis {
330    /// Constructs a new `ManagedApis` instance from a list of API
331    /// configurations.
332    ///
333    /// This is the main entry point for creating a new `ManagedApis` instance.
334    /// Accepts any iterable of items that can be converted into [`ManagedApi`],
335    /// including `Vec<ManagedApiConfig>` and `Vec<ManagedApi>`.
336    pub fn new<I>(api_list: I) -> anyhow::Result<ManagedApis>
337    where
338        I: IntoIterator,
339        I::Item: Into<ManagedApi>,
340    {
341        let mut apis = BTreeMap::new();
342        for api in api_list {
343            let api = api.into();
344            if let Some(old) = apis.insert(api.ident.clone(), api) {
345                bail!("API is defined twice: {:?}", &old.ident);
346            }
347        }
348
349        Ok(ManagedApis {
350            apis,
351            unknown_apis: BTreeSet::new(),
352            validation: None,
353            use_git_stub_storage: false,
354        })
355    }
356
357    /// Adds the given API identifiers (without the ending `.json`) to the list
358    /// of unknown APIs.
359    ///
360    /// By default, if an unknown `.json` file is encountered within the OpenAPI
361    /// directory, a failure is produced. Use this method to produce a warning
362    /// for an allowlist of APIs instead.
363    pub fn with_unknown_apis<I, S>(mut self, apis: I) -> Self
364    where
365        I: IntoIterator<Item = S>,
366        S: Into<ApiIdent>,
367    {
368        self.unknown_apis.extend(apis.into_iter().map(|s| s.into()));
369        self
370    }
371
372    /// Sets a validation function to be used for all APIs.
373    ///
374    /// This function will be called for each API document. The
375    /// [`ValidationContext`] can be used to report errors, as well as extra
376    /// files for which the contents need to be compared with those on disk.
377    pub fn with_validation<F>(mut self, validation: F) -> Self
378    where
379        F: Fn(&OpenAPI, ValidationContext<'_>) + Send + Sync + 'static,
380    {
381        self.validation = Some(Box::new(validation));
382        self
383    }
384
385    /// Returns the validation function for all APIs.
386    pub(crate) fn validation(&self) -> Option<&DynValidationFn> {
387        self.validation.as_deref()
388    }
389
390    /// Enables Git stub storage for older blessed API versions.
391    ///
392    /// When enabled, non-latest blessed API versions are stored as `.gitstub`
393    /// files containing a Git stub instead of full JSON files. This allows
394    /// for Git (including the GitHub web UI) to detect changed OpenAPI
395    /// documents as renames, but Git history is required to be present to read
396    /// older versions.
397    ///
398    /// Individual APIs can override this setting using
399    /// [`ManagedApi::with_git_stub_storage`] or
400    /// [`ManagedApi::disable_git_stub_storage`].
401    pub fn with_git_stub_storage(mut self) -> Self {
402        self.use_git_stub_storage = true;
403        self
404    }
405
406    /// Returns true if Git stub storage is enabled for the given API.
407    ///
408    /// This checks the per-API setting first, falling back to the global
409    /// setting if not specified.
410    pub(crate) fn uses_git_stub_storage(&self, api: &ManagedApi) -> bool {
411        api.uses_git_stub_storage().unwrap_or(self.use_git_stub_storage)
412    }
413
414    /// Returns the number of APIs managed by this instance.
415    pub fn len(&self) -> usize {
416        self.apis.len()
417    }
418
419    /// Returns true if there are no APIs managed by this instance.
420    pub fn is_empty(&self) -> bool {
421        self.apis.is_empty()
422    }
423
424    pub(crate) fn iter_apis(
425        &self,
426    ) -> impl Iterator<Item = &'_ ManagedApi> + '_ {
427        self.apis.values()
428    }
429
430    pub(crate) fn api(&self, ident: &ApiIdent) -> Option<&ManagedApi> {
431        self.apis.get(ident)
432    }
433
434    /// Returns the set of unknown APIs.
435    pub fn unknown_apis(&self) -> &BTreeSet<ApiIdent> {
436        &self.unknown_apis
437    }
438}