dropshot-api-manager 0.7.0

Manage OpenAPI documents generated by Dropshot
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
// Copyright 2026 Oxide Computer Company

use crate::validation::DynValidationFn;
use anyhow::{Context, bail};
use dropshot::{ApiDescription, ApiDescriptionBuildErrors, StubContext};
use dropshot_api_manager_types::{
    ApiIdent, IterVersionsSemvers, ManagedApiMetadata, SupportedVersion,
    ValidationContext, Versions,
};
use openapiv3::OpenAPI;
use std::{
    collections::{BTreeMap, BTreeSet},
    fmt,
};

/// Describes an API managed by the Dropshot API manager.
///
/// Each API listed within a `ManagedApiConfig` forms a unit managed by the
/// Dropshot API manager.
#[derive(Clone, Debug)]
pub struct ManagedApiConfig {
    /// The API-specific part of the filename that's used for API descriptions
    ///
    /// This string is sometimes used as an identifier for developers.
    pub ident: &'static str,

    /// how this API is versioned
    pub versions: Versions,

    /// title of the API (goes into OpenAPI document)
    pub title: &'static str,

    /// metadata about the API
    pub metadata: ManagedApiMetadata,

    /// The API description function, typically a reference to
    /// `stub_api_description`
    ///
    /// This is used to generate the OpenAPI document that matches the current
    /// server implementation.
    pub api_description:
        fn() -> Result<ApiDescription<StubContext>, ApiDescriptionBuildErrors>,
}

/// Describes an API managed by the Dropshot API manager.
///
/// This type is typically created from a [`ManagedApiConfig`] and can be
/// further configured using builder methods before being passed to
/// [`ManagedApis::new`].
pub struct ManagedApi {
    /// The API-specific part of the filename that's used for API descriptions
    ///
    /// This string is sometimes used as an identifier for developers.
    ident: ApiIdent,

    /// how this API is versioned
    versions: Versions,

    /// title of the API (goes into OpenAPI document)
    title: &'static str,

    /// metadata about the API
    metadata: ManagedApiMetadata,

    /// The API description function, typically a reference to
    /// `stub_api_description`
    ///
    /// This is used to generate the OpenAPI document that matches the current
    /// server implementation.
    api_description:
        fn() -> Result<ApiDescription<StubContext>, ApiDescriptionBuildErrors>,

    /// Extra validation to perform on the OpenAPI document, if any.
    ///
    /// For versioned APIs, extra validation is performed on *all* versions,
    /// including blessed ones. You may want to skip performing validation on
    /// blessed versions, though, because they're immutable. To do so, use
    /// [`ValidationContext::is_blessed`].
    extra_validation: Option<Box<DynValidationFn>>,

    /// If true, allow trivial changes (doc updates, type renames) for the
    /// latest blessed version without requiring version bumps.
    ///
    /// Default: false (bytewise check is performed for latest version).
    allow_trivial_changes_for_latest: bool,

    /// Per-API override for Git stub storage.
    ///
    /// - `None`: use the global setting from `ManagedApis`.
    /// - `Some(true)`: enable Git stub storage for this API.
    /// - `Some(false)`: disable Git stub storage for this API.
    use_git_stub_storage: Option<bool>,
}

impl fmt::Debug for ManagedApi {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let Self {
            ident,
            versions,
            title,
            metadata,
            api_description: _,
            extra_validation,
            allow_trivial_changes_for_latest,
            use_git_stub_storage,
        } = self;

        f.debug_struct("ManagedApi")
            .field("ident", ident)
            .field("versions", versions)
            .field("title", title)
            .field("metadata", metadata)
            .field("api_description", &"...")
            .field(
                "extra_validation",
                &extra_validation.as_ref().map(|_| "..."),
            )
            .field(
                "allow_trivial_changes_for_latest",
                allow_trivial_changes_for_latest,
            )
            .field("use_git_stub_storage", use_git_stub_storage)
            .finish()
    }
}

impl From<ManagedApiConfig> for ManagedApi {
    fn from(value: ManagedApiConfig) -> Self {
        let ManagedApiConfig {
            ident,
            versions,
            title,
            metadata,
            api_description,
        } = value;
        ManagedApi {
            ident: ApiIdent::from(ident),
            versions,
            title,
            metadata,
            api_description,
            extra_validation: None,
            allow_trivial_changes_for_latest: false,
            use_git_stub_storage: None,
        }
    }
}

impl ManagedApi {
    /// Returns the API identifier.
    pub fn ident(&self) -> &ApiIdent {
        &self.ident
    }

    /// Returns the API versions.
    pub fn versions(&self) -> &Versions {
        &self.versions
    }

    /// Returns the API title.
    pub fn title(&self) -> &'static str {
        self.title
    }

    /// Returns the API metadata.
    pub fn metadata(&self) -> &ManagedApiMetadata {
        &self.metadata
    }

    /// Returns true if the API is lockstep.
    pub fn is_lockstep(&self) -> bool {
        self.versions.is_lockstep()
    }

    /// Returns true if the API is versioned.
    pub fn is_versioned(&self) -> bool {
        self.versions.is_versioned()
    }

    /// Allows trivial changes (doc updates, type renames) for the latest
    /// blessed version without requiring a version bump.
    ///
    /// By default, the latest blessed version requires bytewise equality
    /// between blessed and generated documents. This prevents trivial changes
    /// from accumulating invisibly. Calling this method allows semantic-only
    /// checking for all versions, including the latest.
    pub fn allow_trivial_changes_for_latest(mut self) -> Self {
        self.allow_trivial_changes_for_latest = true;
        self
    }

    /// Returns true if trivial changes are allowed for the latest version.
    pub fn allows_trivial_changes_for_latest(&self) -> bool {
        self.allow_trivial_changes_for_latest
    }

    /// Enables Git stub storage for this API, overriding the global setting.
    ///
    /// When enabled, non-latest blessed API versions are stored as `.gitstub`
    /// files containing a Git stub instead of full JSON files.
    pub fn with_git_stub_storage(mut self) -> Self {
        self.use_git_stub_storage = Some(true);
        self
    }

    /// Disables Git stub storage for this API, overriding the global setting.
    pub fn disable_git_stub_storage(mut self) -> Self {
        self.use_git_stub_storage = Some(false);
        self
    }

    /// Returns the Git stub storage setting for this API.
    ///
    /// - `None`: use the global setting.
    /// - `Some(true)`: Git stub storage is enabled for this API.
    /// - `Some(false)`: Git stub storage is disabled for this API.
    pub fn uses_git_stub_storage(&self) -> Option<bool> {
        self.use_git_stub_storage
    }

    /// Sets extra validation to perform on the OpenAPI document.
    ///
    /// For versioned APIs, extra validation is performed on *all* versions,
    /// including blessed ones. You may want to skip performing validation on
    /// blessed versions, though, because they're immutable. To do so, use
    /// [`ValidationContext::is_blessed`].
    pub fn with_extra_validation<F>(mut self, f: F) -> Self
    where
        F: Fn(&OpenAPI, ValidationContext<'_>) + Send + Sync + 'static,
    {
        self.extra_validation = Some(Box::new(f));
        self
    }

    pub(crate) fn iter_versioned_versions(
        &self,
    ) -> Option<impl Iterator<Item = &SupportedVersion> + '_> {
        self.versions.iter_versioned_versions()
    }

    pub(crate) fn iter_versions_semver(&self) -> IterVersionsSemvers<'_> {
        self.versions.iter_versions_semvers()
    }

    pub(crate) fn generate_openapi_doc(
        &self,
        version: &semver::Version,
    ) -> anyhow::Result<OpenAPI> {
        // It's a bit weird to first convert to bytes and then back to OpenAPI,
        // but this is the easiest way to do so (currently, Dropshot doesn't
        // return the OpenAPI type directly). It is also consistent with the
        // other code paths.
        let contents = self.generate_spec_bytes(version)?;
        serde_json::from_slice(&contents)
            .context("generated document is not valid OpenAPI")
    }

    pub(crate) fn generate_spec_bytes(
        &self,
        version: &semver::Version,
    ) -> anyhow::Result<Vec<u8>> {
        let description = (self.api_description)().map_err(|error| {
            // ApiDescriptionBuildError is actually a list of errors so it
            // doesn't implement std::error::Error itself. Its Display
            // impl formats the errors appropriately.
            anyhow::anyhow!("{}", error)
        })?;
        let mut openapi_def = description.openapi(self.title, version.clone());
        if let Some(description) = self.metadata.description {
            openapi_def.description(description);
        }
        if let Some(contact_url) = self.metadata.contact_url {
            openapi_def.contact_url(contact_url);
        }
        if let Some(contact_email) = self.metadata.contact_email {
            openapi_def.contact_email(contact_email);
        }

        // Use write because it's the most reliable way to get the canonical
        // JSON order. The `json` method returns a serde_json::Value which may
        // or may not have preserve_order enabled.
        let mut contents = Vec::new();
        openapi_def.write(&mut contents)?;
        Ok(contents)
    }

    pub(crate) fn extra_validation(
        &self,
        openapi: &OpenAPI,
        validation_context: ValidationContext<'_>,
    ) {
        if let Some(extra_validation) = &self.extra_validation {
            extra_validation(openapi, validation_context);
        }
    }
}

/// Describes the Rust-defined configuration for all of the APIs managed by this
/// tool.
///
/// This is repo-specific state that's passed into the OpenAPI manager.
pub struct ManagedApis {
    apis: BTreeMap<ApiIdent, ManagedApi>,
    unknown_apis: BTreeSet<ApiIdent>,
    validation: Option<Box<DynValidationFn>>,

    /// If true, store non-latest blessed API versions as Git stubs instead
    /// of full JSON files. This saves disk space but requires VCS access
    /// (Git or Jujutsu) to read the contents.
    ///
    /// The default is false.
    use_git_stub_storage: bool,
}

impl fmt::Debug for ManagedApis {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let Self { apis, unknown_apis, validation, use_git_stub_storage } =
            self;

        f.debug_struct("ManagedApis")
            .field("apis", apis)
            .field("unknown_apis", unknown_apis)
            .field("validation", &validation.as_ref().map(|_| "..."))
            .field("use_git_stub_storage", use_git_stub_storage)
            .finish()
    }
}

impl ManagedApis {
    /// Constructs a new `ManagedApis` instance from a list of API
    /// configurations.
    ///
    /// This is the main entry point for creating a new `ManagedApis` instance.
    /// Accepts any iterable of items that can be converted into [`ManagedApi`],
    /// including `Vec<ManagedApiConfig>` and `Vec<ManagedApi>`.
    pub fn new<I>(api_list: I) -> anyhow::Result<ManagedApis>
    where
        I: IntoIterator,
        I::Item: Into<ManagedApi>,
    {
        let mut apis = BTreeMap::new();
        for api in api_list {
            let api = api.into();
            if let Some(old) = apis.insert(api.ident.clone(), api) {
                bail!("API is defined twice: {:?}", &old.ident);
            }
        }

        Ok(ManagedApis {
            apis,
            unknown_apis: BTreeSet::new(),
            validation: None,
            use_git_stub_storage: false,
        })
    }

    /// Adds the given API identifiers (without the ending `.json`) to the list
    /// of unknown APIs.
    ///
    /// By default, if an unknown `.json` file is encountered within the OpenAPI
    /// directory, a failure is produced. Use this method to produce a warning
    /// for an allowlist of APIs instead.
    pub fn with_unknown_apis<I, S>(mut self, apis: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<ApiIdent>,
    {
        self.unknown_apis.extend(apis.into_iter().map(|s| s.into()));
        self
    }

    /// Sets a validation function to be used for all APIs.
    ///
    /// This function will be called for each API document. The
    /// [`ValidationContext`] can be used to report errors, as well as extra
    /// files for which the contents need to be compared with those on disk.
    pub fn with_validation<F>(mut self, validation: F) -> Self
    where
        F: Fn(&OpenAPI, ValidationContext<'_>) + Send + Sync + 'static,
    {
        self.validation = Some(Box::new(validation));
        self
    }

    /// Returns the validation function for all APIs.
    pub(crate) fn validation(&self) -> Option<&DynValidationFn> {
        self.validation.as_deref()
    }

    /// Enables Git stub storage for older blessed API versions.
    ///
    /// When enabled, non-latest blessed API versions are stored as `.gitstub`
    /// files containing a Git stub instead of full JSON files. This allows
    /// for Git (including the GitHub web UI) to detect changed OpenAPI
    /// documents as renames, but Git history is required to be present to read
    /// older versions.
    ///
    /// Individual APIs can override this setting using
    /// [`ManagedApi::with_git_stub_storage`] or
    /// [`ManagedApi::disable_git_stub_storage`].
    pub fn with_git_stub_storage(mut self) -> Self {
        self.use_git_stub_storage = true;
        self
    }

    /// Returns true if Git stub storage is enabled for the given API.
    ///
    /// This checks the per-API setting first, falling back to the global
    /// setting if not specified.
    pub(crate) fn uses_git_stub_storage(&self, api: &ManagedApi) -> bool {
        api.uses_git_stub_storage().unwrap_or(self.use_git_stub_storage)
    }

    /// Returns the number of APIs managed by this instance.
    pub fn len(&self) -> usize {
        self.apis.len()
    }

    /// Returns true if there are no APIs managed by this instance.
    pub fn is_empty(&self) -> bool {
        self.apis.is_empty()
    }

    pub(crate) fn iter_apis(
        &self,
    ) -> impl Iterator<Item = &'_ ManagedApi> + '_ {
        self.apis.values()
    }

    pub(crate) fn api(&self, ident: &ApiIdent) -> Option<&ManagedApi> {
        self.apis.get(ident)
    }

    /// Returns the set of unknown APIs.
    pub fn unknown_apis(&self) -> &BTreeSet<ApiIdent> {
        &self.unknown_apis
    }
}