dropshot_api_manager/
apis.rs

1// Copyright 2025 Oxide Computer Company
2
3use anyhow::{Context, bail};
4use dropshot::{ApiDescription, ApiDescriptionBuildErrors, StubContext};
5use dropshot_api_manager_types::{
6    ApiIdent, IterVersionsSemvers, ManagedApiMetadata, SupportedVersion,
7    ValidationContext, Versions,
8};
9use openapiv3::OpenAPI;
10use std::collections::{BTreeMap, BTreeSet};
11
12/// Describes an API managed by the Dropshot API manager.
13///
14/// Each API listed within a `ManagedApiConfig` forms a unit managed by the
15/// Dropshot API manager.
16#[derive(Clone, Debug)]
17pub struct ManagedApiConfig {
18    /// The API-specific part of the filename that's used for API descriptions
19    ///
20    /// This string is sometimes used as an identifier for developers.
21    pub ident: &'static str,
22
23    /// how this API is versioned
24    pub versions: Versions,
25
26    /// title of the API (goes into OpenAPI spec)
27    pub title: &'static str,
28
29    /// metadata about the API
30    pub metadata: ManagedApiMetadata,
31
32    /// The API description function, typically a reference to
33    /// `stub_api_description`
34    ///
35    /// This is used to generate the OpenAPI document that matches the current
36    /// server implementation.
37    pub api_description:
38        fn() -> Result<ApiDescription<StubContext>, ApiDescriptionBuildErrors>,
39
40    /// Extra validation to perform on the OpenAPI document, if any.
41    ///
42    /// For versioned APIs, extra validation is performed on *all* versions,
43    /// including blessed ones. You may want to skip performing validation on
44    /// blessed versions, though, because they're immutable. To do so, use
45    /// [`ValidationContext::is_blessed`].
46    pub extra_validation: Option<fn(&OpenAPI, ValidationContext<'_>)>,
47}
48
49/// Used internally to describe an API managed by this tool.
50#[derive(Debug)]
51pub(crate) struct ManagedApi {
52    /// The API-specific part of the filename that's used for API descriptions
53    ///
54    /// This string is sometimes used as an identifier for developers.
55    ident: ApiIdent,
56
57    /// how this API is versioned
58    versions: Versions,
59
60    /// title of the API (goes into OpenAPI spec)
61    title: &'static str,
62
63    /// metadata about the API
64    metadata: ManagedApiMetadata,
65
66    /// The API description function, typically a reference to
67    /// `stub_api_description`
68    ///
69    /// This is used to generate the OpenAPI document that matches the current
70    /// server implementation.
71    api_description:
72        fn() -> Result<ApiDescription<StubContext>, ApiDescriptionBuildErrors>,
73
74    /// Extra validation to perform on the OpenAPI document, if any.
75    extra_validation: Option<fn(&OpenAPI, ValidationContext<'_>)>,
76}
77
78impl From<ManagedApiConfig> for ManagedApi {
79    fn from(value: ManagedApiConfig) -> Self {
80        ManagedApi {
81            ident: ApiIdent::from(value.ident.to_owned()),
82            versions: value.versions,
83            title: value.title,
84            metadata: value.metadata,
85            api_description: value.api_description,
86            extra_validation: value.extra_validation,
87        }
88    }
89}
90
91impl ManagedApi {
92    pub fn ident(&self) -> &ApiIdent {
93        &self.ident
94    }
95
96    pub fn versions(&self) -> &Versions {
97        &self.versions
98    }
99
100    pub fn title(&self) -> &'static str {
101        self.title
102    }
103
104    pub fn metadata(&self) -> &ManagedApiMetadata {
105        &self.metadata
106    }
107
108    pub fn is_lockstep(&self) -> bool {
109        self.versions.is_lockstep()
110    }
111
112    pub fn is_versioned(&self) -> bool {
113        self.versions.is_versioned()
114    }
115
116    pub fn iter_versioned_versions(
117        &self,
118    ) -> Option<impl Iterator<Item = &SupportedVersion> + '_> {
119        self.versions.iter_versioned_versions()
120    }
121
122    pub fn iter_versions_semver(&self) -> IterVersionsSemvers<'_> {
123        self.versions.iter_versions_semvers()
124    }
125
126    pub fn generate_openapi_doc(
127        &self,
128        version: &semver::Version,
129    ) -> anyhow::Result<OpenAPI> {
130        // It's a bit weird to first convert to bytes and then back to OpenAPI,
131        // but this is the easiest way to do so (currently, Dropshot doesn't
132        // return the OpenAPI type directly). It is also consistent with the
133        // other code paths.
134        let contents = self.generate_spec_bytes(version)?;
135        serde_json::from_slice(&contents)
136            .context("generated document is not valid OpenAPI")
137    }
138
139    pub fn generate_spec_bytes(
140        &self,
141        version: &semver::Version,
142    ) -> anyhow::Result<Vec<u8>> {
143        let description = (self.api_description)().map_err(|error| {
144            // ApiDescriptionBuildError is actually a list of errors so it
145            // doesn't implement std::error::Error itself. Its Display
146            // impl formats the errors appropriately.
147            anyhow::anyhow!("{}", error)
148        })?;
149        let mut openapi_def = description.openapi(self.title, version.clone());
150        if let Some(description) = self.metadata.description {
151            openapi_def.description(description);
152        }
153        if let Some(contact_url) = self.metadata.contact_url {
154            openapi_def.contact_url(contact_url);
155        }
156        if let Some(contact_email) = self.metadata.contact_email {
157            openapi_def.contact_email(contact_email);
158        }
159
160        // Use write because it's the most reliable way to get the canonical
161        // JSON order. The `json` method returns a serde_json::Value which may
162        // or may not have preserve_order enabled.
163        let mut contents = Vec::new();
164        openapi_def.write(&mut contents)?;
165        Ok(contents)
166    }
167
168    pub fn extra_validation(
169        &self,
170        openapi: &OpenAPI,
171        validation_context: ValidationContext<'_>,
172    ) {
173        if let Some(extra_validation) = self.extra_validation {
174            extra_validation(openapi, validation_context);
175        }
176    }
177}
178
179/// Describes the Rust-defined configuration for all of the APIs managed by this
180/// tool.
181///
182/// This is repo-specific state that's passed into the OpenAPI manager.
183#[derive(Debug)]
184pub struct ManagedApis {
185    apis: BTreeMap<ApiIdent, ManagedApi>,
186    unknown_apis: BTreeSet<ApiIdent>,
187    validation: Option<fn(&OpenAPI, ValidationContext<'_>)>,
188}
189
190impl ManagedApis {
191    /// Constructs a new `ManagedApis` instance from a list of API
192    /// configurations.
193    ///
194    /// This is the main entry point for creating a new `ManagedApis` instance.
195    pub fn new(api_list: Vec<ManagedApiConfig>) -> anyhow::Result<ManagedApis> {
196        let mut apis = BTreeMap::new();
197        for api in api_list {
198            let api = ManagedApi::from(api);
199            if let Some(old) = apis.insert(api.ident.clone(), api) {
200                bail!("API is defined twice: {:?}", &old.ident);
201            }
202        }
203
204        Ok(ManagedApis {
205            apis,
206            unknown_apis: BTreeSet::new(),
207            validation: None,
208        })
209    }
210
211    /// Adds the given API identifiers (without the ending `.json`) to the list
212    /// of unknown APIs.
213    ///
214    /// By default, if an unknown `.json` file is encountered within the OpenAPI
215    /// directory, a failure is produced. Use this method to produce a warning
216    /// for an allowlist of APIs instead.
217    pub fn with_unknown_apis<I, S>(mut self, apis: I) -> Self
218    where
219        I: IntoIterator<Item = S>,
220        S: Into<ApiIdent>,
221    {
222        self.unknown_apis.extend(apis.into_iter().map(|s| s.into()));
223        self
224    }
225
226    /// Sets a validation function to be used for all APIs.
227    ///
228    /// This function will be called for each API document. The
229    /// [`ValidationContext`] can be used to report errors, as well as extra
230    /// files for which the contents need to be compared with those on disk.
231    pub fn with_validation(
232        mut self,
233        validation: fn(&OpenAPI, ValidationContext<'_>),
234    ) -> Self {
235        self.validation = Some(validation);
236        self
237    }
238
239    /// Returns the validation function for all APIs.
240    pub fn validation(&self) -> Option<fn(&OpenAPI, ValidationContext<'_>)> {
241        self.validation
242    }
243
244    /// Returns the number of APIs managed by this instance.
245    pub fn len(&self) -> usize {
246        self.apis.len()
247    }
248
249    /// Returns true if there are no APIs managed by this instance.
250    pub fn is_empty(&self) -> bool {
251        self.apis.is_empty()
252    }
253
254    pub(crate) fn iter_apis(
255        &self,
256    ) -> impl Iterator<Item = &'_ ManagedApi> + '_ {
257        self.apis.values()
258    }
259
260    pub(crate) fn api(&self, ident: &ApiIdent) -> Option<&ManagedApi> {
261        self.apis.get(ident)
262    }
263
264    /// Returns the set of unknown APIs.
265    pub fn unknown_apis(&self) -> &BTreeSet<ApiIdent> {
266        &self.unknown_apis
267    }
268}