dropshot_api_manager/
apis.rs

1// Copyright 2025 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 spec)
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 spec)
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
88impl fmt::Debug for ManagedApi {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        let Self {
91            ident,
92            versions,
93            title,
94            metadata,
95            api_description: _,
96            extra_validation,
97            allow_trivial_changes_for_latest,
98        } = self;
99
100        f.debug_struct("ManagedApi")
101            .field("ident", ident)
102            .field("versions", versions)
103            .field("title", title)
104            .field("metadata", metadata)
105            .field("api_description", &"...")
106            .field(
107                "extra_validation",
108                &extra_validation.as_ref().map(|_| "..."),
109            )
110            .field(
111                "allow_trivial_changes_for_latest",
112                allow_trivial_changes_for_latest,
113            )
114            .finish()
115    }
116}
117
118impl From<ManagedApiConfig> for ManagedApi {
119    fn from(value: ManagedApiConfig) -> Self {
120        let ManagedApiConfig {
121            ident,
122            versions,
123            title,
124            metadata,
125            api_description,
126        } = value;
127        ManagedApi {
128            ident: ApiIdent::from(ident.to_owned()),
129            versions,
130            title,
131            metadata,
132            api_description,
133            extra_validation: None,
134            allow_trivial_changes_for_latest: false,
135        }
136    }
137}
138
139impl ManagedApi {
140    /// Returns the API identifier.
141    pub fn ident(&self) -> &ApiIdent {
142        &self.ident
143    }
144
145    /// Returns the API versions.
146    pub fn versions(&self) -> &Versions {
147        &self.versions
148    }
149
150    /// Returns the API title.
151    pub fn title(&self) -> &'static str {
152        self.title
153    }
154
155    /// Returns the API metadata.
156    pub fn metadata(&self) -> &ManagedApiMetadata {
157        &self.metadata
158    }
159
160    /// Returns true if the API is lockstep.
161    pub fn is_lockstep(&self) -> bool {
162        self.versions.is_lockstep()
163    }
164
165    /// Returns true if the API is versioned.
166    pub fn is_versioned(&self) -> bool {
167        self.versions.is_versioned()
168    }
169
170    /// Allows trivial changes (doc updates, type renames) for the latest
171    /// blessed version without requiring a version bump.
172    ///
173    /// By default, the latest blessed version requires bytewise equality
174    /// between blessed and generated documents. This prevents trivial changes
175    /// from accumulating invisibly. Calling this method allows semantic-only
176    /// checking for all versions, including the latest.
177    pub fn allow_trivial_changes_for_latest(mut self) -> Self {
178        self.allow_trivial_changes_for_latest = true;
179        self
180    }
181
182    /// Returns true if trivial changes are allowed for the latest version.
183    pub fn allows_trivial_changes_for_latest(&self) -> bool {
184        self.allow_trivial_changes_for_latest
185    }
186
187    /// Sets extra validation to perform on the OpenAPI document.
188    ///
189    /// For versioned APIs, extra validation is performed on *all* versions,
190    /// including blessed ones. You may want to skip performing validation on
191    /// blessed versions, though, because they're immutable. To do so, use
192    /// [`ValidationContext::is_blessed`].
193    pub fn with_extra_validation<F>(mut self, f: F) -> Self
194    where
195        F: Fn(&OpenAPI, ValidationContext<'_>) + Send + 'static,
196    {
197        self.extra_validation = Some(Box::new(f));
198        self
199    }
200
201    pub(crate) fn iter_versioned_versions(
202        &self,
203    ) -> Option<impl Iterator<Item = &SupportedVersion> + '_> {
204        self.versions.iter_versioned_versions()
205    }
206
207    pub(crate) fn iter_versions_semver(&self) -> IterVersionsSemvers<'_> {
208        self.versions.iter_versions_semvers()
209    }
210
211    pub(crate) fn generate_openapi_doc(
212        &self,
213        version: &semver::Version,
214    ) -> anyhow::Result<OpenAPI> {
215        // It's a bit weird to first convert to bytes and then back to OpenAPI,
216        // but this is the easiest way to do so (currently, Dropshot doesn't
217        // return the OpenAPI type directly). It is also consistent with the
218        // other code paths.
219        let contents = self.generate_spec_bytes(version)?;
220        serde_json::from_slice(&contents)
221            .context("generated document is not valid OpenAPI")
222    }
223
224    pub(crate) fn generate_spec_bytes(
225        &self,
226        version: &semver::Version,
227    ) -> anyhow::Result<Vec<u8>> {
228        let description = (self.api_description)().map_err(|error| {
229            // ApiDescriptionBuildError is actually a list of errors so it
230            // doesn't implement std::error::Error itself. Its Display
231            // impl formats the errors appropriately.
232            anyhow::anyhow!("{}", error)
233        })?;
234        let mut openapi_def = description.openapi(self.title, version.clone());
235        if let Some(description) = self.metadata.description {
236            openapi_def.description(description);
237        }
238        if let Some(contact_url) = self.metadata.contact_url {
239            openapi_def.contact_url(contact_url);
240        }
241        if let Some(contact_email) = self.metadata.contact_email {
242            openapi_def.contact_email(contact_email);
243        }
244
245        // Use write because it's the most reliable way to get the canonical
246        // JSON order. The `json` method returns a serde_json::Value which may
247        // or may not have preserve_order enabled.
248        let mut contents = Vec::new();
249        openapi_def.write(&mut contents)?;
250        Ok(contents)
251    }
252
253    pub(crate) fn extra_validation(
254        &self,
255        openapi: &OpenAPI,
256        validation_context: ValidationContext<'_>,
257    ) {
258        if let Some(extra_validation) = &self.extra_validation {
259            extra_validation(openapi, validation_context);
260        }
261    }
262}
263
264/// Describes the Rust-defined configuration for all of the APIs managed by this
265/// tool.
266///
267/// This is repo-specific state that's passed into the OpenAPI manager.
268pub struct ManagedApis {
269    apis: BTreeMap<ApiIdent, ManagedApi>,
270    unknown_apis: BTreeSet<ApiIdent>,
271    validation: Option<Box<DynValidationFn>>,
272}
273
274impl fmt::Debug for ManagedApis {
275    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
276        let Self { apis, unknown_apis, validation } = self;
277
278        f.debug_struct("ManagedApis")
279            .field("apis", apis)
280            .field("unknown_apis", unknown_apis)
281            .field("validation", &validation.as_ref().map(|_| "..."))
282            .finish()
283    }
284}
285
286impl ManagedApis {
287    /// Constructs a new `ManagedApis` instance from a list of API
288    /// configurations.
289    ///
290    /// This is the main entry point for creating a new `ManagedApis` instance.
291    /// Accepts any iterable of items that can be converted into [`ManagedApi`],
292    /// including `Vec<ManagedApiConfig>` and `Vec<ManagedApi>`.
293    pub fn new<I>(api_list: I) -> anyhow::Result<ManagedApis>
294    where
295        I: IntoIterator,
296        I::Item: Into<ManagedApi>,
297    {
298        let mut apis = BTreeMap::new();
299        for api in api_list {
300            let api = api.into();
301            if let Some(old) = apis.insert(api.ident.clone(), api) {
302                bail!("API is defined twice: {:?}", &old.ident);
303            }
304        }
305
306        Ok(ManagedApis {
307            apis,
308            unknown_apis: BTreeSet::new(),
309            validation: None,
310        })
311    }
312
313    /// Adds the given API identifiers (without the ending `.json`) to the list
314    /// of unknown APIs.
315    ///
316    /// By default, if an unknown `.json` file is encountered within the OpenAPI
317    /// directory, a failure is produced. Use this method to produce a warning
318    /// for an allowlist of APIs instead.
319    pub fn with_unknown_apis<I, S>(mut self, apis: I) -> Self
320    where
321        I: IntoIterator<Item = S>,
322        S: Into<ApiIdent>,
323    {
324        self.unknown_apis.extend(apis.into_iter().map(|s| s.into()));
325        self
326    }
327
328    /// Sets a validation function to be used for all APIs.
329    ///
330    /// This function will be called for each API document. The
331    /// [`ValidationContext`] can be used to report errors, as well as extra
332    /// files for which the contents need to be compared with those on disk.
333    pub fn with_validation<F>(mut self, validation: F) -> Self
334    where
335        F: Fn(&OpenAPI, ValidationContext<'_>) + Send + 'static,
336    {
337        self.validation = Some(Box::new(validation));
338        self
339    }
340
341    /// Returns the validation function for all APIs.
342    pub(crate) fn validation(&self) -> Option<&DynValidationFn> {
343        self.validation.as_deref()
344    }
345
346    /// Returns the number of APIs managed by this instance.
347    pub fn len(&self) -> usize {
348        self.apis.len()
349    }
350
351    /// Returns true if there are no APIs managed by this instance.
352    pub fn is_empty(&self) -> bool {
353        self.apis.is_empty()
354    }
355
356    pub(crate) fn iter_apis(
357        &self,
358    ) -> impl Iterator<Item = &'_ ManagedApi> + '_ {
359        self.apis.values()
360    }
361
362    pub(crate) fn api(&self, ident: &ApiIdent) -> Option<&ManagedApi> {
363        self.apis.get(ident)
364    }
365
366    /// Returns the set of unknown APIs.
367    pub fn unknown_apis(&self) -> &BTreeSet<ApiIdent> {
368        &self.unknown_apis
369    }
370}