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