Skip to content

Commit 92fd517

Browse files
authored
support extra validation for versioned APIs (#25)
Include information about: * whether this is the latest version, for use with determining whether extra files should be written out. * whether this is a blessed version, in which case users may want to skip part or all of validation.
1 parent bb71488 commit 92fd517

File tree

9 files changed

+428
-38
lines changed

9 files changed

+428
-38
lines changed

crates/dropshot-api-manager-types/src/validation.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,29 @@ impl<'a> ValidationContext<'a> {
2525
}
2626

2727
/// Returns a descriptor for the API's file name.
28+
///
29+
/// The file name can be used to identify the version of the API being
30+
/// validated.
2831
pub fn file_name(&self) -> &ApiSpecFileName {
2932
self.backend.file_name()
3033
}
3134

35+
/// Returns true if this is the latest version of a versioned API, or if the
36+
/// API is lockstep.
37+
///
38+
/// This is particularly useful for extra files which might not themselves
39+
/// be versioned. In that case, you may wish to only generate the extra file
40+
/// for the latest version.
41+
pub fn is_latest(&self) -> bool {
42+
self.backend.is_latest()
43+
}
44+
45+
/// Returns whether this version is blessed, or None if this is not a
46+
/// versioned API.
47+
pub fn is_blessed(&self) -> Option<bool> {
48+
self.backend.is_blessed()
49+
}
50+
3251
/// Retrieves the versioning strategy for this API.
3352
pub fn versions(&self) -> &Versions {
3453
self.backend.versions()
@@ -73,6 +92,8 @@ pub trait ValidationBackend {
7392
fn ident(&self) -> &ApiIdent;
7493
fn file_name(&self) -> &ApiSpecFileName;
7594
fn versions(&self) -> &Versions;
95+
fn is_latest(&self) -> bool;
96+
fn is_blessed(&self) -> Option<bool>;
7697
fn title(&self) -> &str;
7798
fn metadata(&self) -> &ManagedApiMetadata;
7899
fn report_error(&mut self, error: anyhow::Error);

crates/dropshot-api-manager-types/src/versions.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,18 @@ impl<'a> Iterator for IterVersionsSemvers<'a> {
166166
}
167167
}
168168

169+
impl<'a> ExactSizeIterator for IterVersionsSemvers<'a> {
170+
fn len(&self) -> usize {
171+
self.inner.len()
172+
}
173+
}
174+
175+
impl<'a> DoubleEndedIterator for IterVersionsSemvers<'a> {
176+
fn next_back(&mut self) -> Option<Self::Item> {
177+
self.inner.next_back()
178+
}
179+
}
180+
169181
#[derive(Debug)]
170182
enum IterVersionsSemversInner<'a> {
171183
Lockstep(Option<&'a semver::Version>),
@@ -200,6 +212,17 @@ impl<'a> ExactSizeIterator for IterVersionsSemversInner<'a> {
200212
}
201213
}
202214

215+
impl<'a> DoubleEndedIterator for IterVersionsSemversInner<'a> {
216+
fn next_back(&mut self) -> Option<Self::Item> {
217+
match self {
218+
IterVersionsSemversInner::Lockstep(version) => version.take(),
219+
IterVersionsSemversInner::Versioned(versions) => {
220+
versions.next_back().map(|v| &v.semver)
221+
}
222+
}
223+
}
224+
}
225+
203226
/// Helper macro used to define API versions.
204227
///
205228
/// ```

crates/dropshot-api-manager/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,3 +346,9 @@ An existing lockstep API can be made versioned. You would do this when transiti
346346
That should be it! Now, when iterating on the API, you'll need to follow the procedure described above for versioned APIs (which is slightly more complicated than the one for lockstep APIs).
347347

348348
In principle, this process could be reversed to convert an API from versioned to lockstep, but this almost certainly has runtime implications that would need to be considered.
349+
350+
## Contributing
351+
352+
Bugfixes and other minor fixes are welcome! Before working on a major feature, please [open an issue](https://github.com/oxidecomputer/dropshot-api-manager/issues/new) to discuss it.
353+
354+
Tests must be run using [cargo-nextest](https://nexte.st/).

crates/dropshot-api-manager/src/apis.rs

Lines changed: 12 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
use anyhow::{Context, bail};
44
use dropshot::{ApiDescription, ApiDescriptionBuildErrors, StubContext};
55
use dropshot_api_manager_types::{
6-
ApiIdent, ManagedApiMetadata, SupportedVersion, ValidationContext, Versions,
6+
ApiIdent, IterVersionsSemvers, ManagedApiMetadata, SupportedVersion,
7+
ValidationContext, Versions,
78
};
89
use openapiv3::OpenAPI;
910
use std::collections::{BTreeMap, BTreeSet};
@@ -31,12 +32,17 @@ pub struct ManagedApiConfig {
3132
/// The API description function, typically a reference to
3233
/// `stub_api_description`
3334
///
34-
/// This is used to generate the OpenAPI spec that matches the current
35+
/// This is used to generate the OpenAPI document that matches the current
3536
/// server implementation.
3637
pub api_description:
3738
fn() -> Result<ApiDescription<StubContext>, ApiDescriptionBuildErrors>,
3839

39-
/// Extra validation to perform on the OpenAPI spec, if any.
40+
/// Extra validation to perform on the OpenAPI document, if any.
41+
///
42+
/// For versioned APIs, extra validation is performed on *all* versions,
43+
/// including blessed ones. You may want to skip performing validation on
44+
/// blessed versions, though, because they're immutable. To do so, use
45+
/// [`ValidationContext::is_blessed`].
4046
pub extra_validation: Option<fn(&OpenAPI, ValidationContext<'_>)>,
4147
}
4248

@@ -60,12 +66,12 @@ pub(crate) struct ManagedApi {
6066
/// The API description function, typically a reference to
6167
/// `stub_api_description`
6268
///
63-
/// This is used to generate the OpenAPI spec that matches the current
69+
/// This is used to generate the OpenAPI document that matches the current
6470
/// server implementation.
6571
api_description:
6672
fn() -> Result<ApiDescription<StubContext>, ApiDescriptionBuildErrors>,
6773

68-
/// Extra validation to perform on the OpenAPI spec, if any.
74+
/// Extra validation to perform on the OpenAPI document, if any.
6975
extra_validation: Option<fn(&OpenAPI, ValidationContext<'_>)>,
7076
}
7177

@@ -113,9 +119,7 @@ impl ManagedApi {
113119
self.versions.iter_versioned_versions()
114120
}
115121

116-
pub fn iter_versions_semver(
117-
&self,
118-
) -> impl Iterator<Item = &semver::Version> + '_ {
122+
pub fn iter_versions_semver(&self) -> IterVersionsSemvers<'_> {
119123
self.versions.iter_versions_semvers()
120124
}
121125

@@ -192,20 +196,6 @@ impl ManagedApis {
192196
let mut apis = BTreeMap::new();
193197
for api in api_list {
194198
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-
209199
if let Some(old) = apis.insert(api.ident.clone(), api) {
210200
bail!("API is defined twice: {:?}", &old.ident);
211201
}

crates/dropshot-api-manager/src/resolved.rs

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -423,10 +423,13 @@ impl Fix<'_> {
423423
CheckStale::Modified { expected, .. } => expected,
424424
CheckStale::New { expected } => expected,
425425
};
426+
// Extra file paths are relative to the repo root, not the
427+
// documents directory.
428+
let full_path = env.repo_root.join(path);
426429
Ok(vec![format!(
427430
"wrote {}: {:?}",
428431
&path,
429-
overwrite_file(path, expected_contents)?
432+
overwrite_file(&full_path, expected_contents)?
430433
)])
431434
}
432435
Fix::UpdateSymlink { api_ident, link } => {
@@ -652,18 +655,32 @@ fn resolve_api<'a>(
652655
} else {
653656
let by_version: BTreeMap<_, _> = api
654657
.iter_versions_semver()
655-
.map(|version| {
658+
// Reverse the order of versions: they are stored in sorted order,
659+
// so the last version (first one from the back) is the latest.
660+
.rev()
661+
.enumerate()
662+
.map(|(index, version)| {
663+
let is_latest = index == 0;
656664
let version = version.clone();
657665
let blessed =
658666
api_blessed.and_then(|b| b.versions().get(&version));
667+
let is_blessed = Some(blessed.is_some());
659668
let generated = api_generated.versions().get(&version).unwrap();
660669
let local = api_local
661670
.and_then(|b| b.versions().get(&version))
662671
.map(|v| v.as_slice())
663672
.unwrap_or(&[]);
673+
664674
let resolution = resolve_api_version(
665-
env, api, validation, &version, blessed, generated, local,
675+
env,
676+
api,
677+
validation,
678+
ApiVersion { version: &version, is_latest, is_blessed },
679+
blessed,
680+
generated,
681+
local,
666682
);
683+
667684
(version, resolution)
668685
})
669686
.collect();
@@ -869,7 +886,18 @@ fn resolve_api_lockstep<'a>(
869886
let mut problems = Vec::new();
870887

871888
// Validate the generated API document.
872-
validate_generated(env, api, validation, version, generated, &mut problems);
889+
validate_generated(
890+
env,
891+
api,
892+
validation,
893+
ApiVersion {
894+
version,
895+
is_latest: true, // is_latest is always true for lockstep APIs
896+
is_blessed: None,
897+
},
898+
generated,
899+
&mut problems,
900+
);
873901

874902
match local {
875903
Some(local_file) if local_file.contents() == generated.contents() => (),
@@ -882,11 +910,17 @@ fn resolve_api_lockstep<'a>(
882910
BTreeMap::from([(version.clone(), Resolution::new_lockstep(problems))])
883911
}
884912

913+
struct ApiVersion<'a> {
914+
version: &'a semver::Version,
915+
is_latest: bool,
916+
is_blessed: Option<bool>,
917+
}
918+
885919
fn resolve_api_version<'a>(
886920
env: &'_ ResolvedEnv,
887921
api: &'_ ManagedApi,
888922
validation: Option<fn(&OpenAPI, ValidationContext<'_>)>,
889-
version: &'_ semver::Version,
923+
version: ApiVersion<'_>,
890924
blessed: Option<&'a BlessedApiSpecFile>,
891925
generated: &'a GeneratedApiSpecFile,
892926
local: &'a [LocalApiSpecFile],
@@ -905,14 +939,21 @@ fn resolve_api_version_blessed<'a>(
905939
env: &'_ ResolvedEnv,
906940
api: &'_ ManagedApi,
907941
validation: Option<fn(&OpenAPI, ValidationContext<'_>)>,
908-
version: &'_ semver::Version,
942+
version: ApiVersion<'_>,
909943
blessed: &'a BlessedApiSpecFile,
910944
generated: &'a GeneratedApiSpecFile,
911945
local: &'a [LocalApiSpecFile],
912946
) -> Resolution<'a> {
913947
let mut problems = Vec::new();
914948

915949
// Validate the generated API document.
950+
//
951+
// Blessed versions are immutable, so why do we call validation on them in a
952+
// way that can fail? The reason is that validation may also want to
953+
// generate extra files, particularly for the latest version. Whether or not
954+
// the API version is blessed, the user might still want to generate extra
955+
// files for that version. So we validate unconditionally, but let the user
956+
// know via `is_blessed`, letting them skip validation where appropriate.
916957
validate_generated(env, api, validation, version, generated, &mut problems);
917958

918959
// First off, the blessed spec must be a subset of the generated one.
@@ -980,13 +1021,13 @@ fn resolve_api_version_local<'a>(
9801021
env: &'_ ResolvedEnv,
9811022
api: &'_ ManagedApi,
9821023
validation: Option<fn(&OpenAPI, ValidationContext<'_>)>,
983-
version: &'_ semver::Version,
1024+
version: ApiVersion<'_>,
9841025
generated: &'a GeneratedApiSpecFile,
9851026
local: &'a [LocalApiSpecFile],
9861027
) -> Resolution<'a> {
9871028
let mut problems = Vec::new();
9881029

989-
// Validate the generated API document.
1030+
// alidate the generated API document.
9901031
validate_generated(env, api, validation, version, generated, &mut problems);
9911032

9921033
let (matching, non_matching): (Vec<_>, Vec<_>) = local
@@ -1021,15 +1062,22 @@ fn validate_generated(
10211062
env: &ResolvedEnv,
10221063
api: &ManagedApi,
10231064
validation: Option<fn(&OpenAPI, ValidationContext<'_>)>,
1024-
version: &semver::Version,
1065+
version: ApiVersion<'_>,
10251066
generated: &GeneratedApiSpecFile,
10261067
problems: &mut Vec<Problem<'_>>,
10271068
) {
1028-
match validate(env, api, validation, generated) {
1069+
match validate(
1070+
env,
1071+
api,
1072+
version.is_latest,
1073+
version.is_blessed,
1074+
validation,
1075+
generated,
1076+
) {
10291077
Err(source) => {
10301078
problems.push(Problem::GeneratedValidationError {
10311079
api_ident: api.ident().clone(),
1032-
version: version.clone(),
1080+
version: version.version.clone(),
10331081
source,
10341082
});
10351083
}

crates/dropshot-api-manager/src/validation.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ use std::io::Write;
1717
pub fn validate(
1818
env: &ResolvedEnv,
1919
api: &ManagedApi,
20+
is_latest: bool,
21+
is_blessed: Option<bool>,
2022
validation: Option<fn(&OpenAPI, ValidationContext<'_>)>,
2123
generated: &GeneratedApiSpecFile,
2224
) -> anyhow::Result<Vec<(Utf8PathBuf, CheckStatus)>> {
@@ -25,6 +27,8 @@ pub fn validate(
2527
api,
2628
openapi,
2729
generated.spec_file_name(),
30+
is_latest,
31+
is_blessed,
2832
validation,
2933
)?;
3034
let extra_files = validation_result
@@ -43,12 +47,16 @@ fn validate_generated_openapi_document(
4347
api: &ManagedApi,
4448
openapi_doc: &OpenAPI,
4549
file_name: &ApiSpecFileName,
50+
is_latest: bool,
51+
is_blessed: Option<bool>,
4652
validation: Option<fn(&OpenAPI, ValidationContext<'_>)>,
4753
) -> anyhow::Result<ValidationResult> {
4854
let mut validation_context = ValidationContextImpl {
4955
ident: api.ident().clone(),
5056
file_name: file_name.clone(),
5157
versions: api.versions().clone(),
58+
is_latest,
59+
is_blessed,
5260
title: api.title(),
5361
metadata: api.metadata().clone(),
5462
errors: Vec::new(),
@@ -182,6 +190,8 @@ struct ValidationContextImpl {
182190
ident: ApiIdent,
183191
file_name: ApiSpecFileName,
184192
versions: Versions,
193+
is_latest: bool,
194+
is_blessed: Option<bool>,
185195
title: &'static str,
186196
metadata: ManagedApiMetadata,
187197
errors: Vec<anyhow::Error>,
@@ -201,6 +211,14 @@ impl ValidationBackend for ValidationContextImpl {
201211
&self.versions
202212
}
203213

214+
fn is_latest(&self) -> bool {
215+
self.is_latest
216+
}
217+
218+
fn is_blessed(&self) -> Option<bool> {
219+
self.is_blessed
220+
}
221+
204222
fn title(&self) -> &str {
205223
self.title
206224
}

0 commit comments

Comments
 (0)