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