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}