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