Skip to content

Commit a8c7dcf

Browse files
authored
compare latest blessed version for bytewise equality (#36)
The Dropshot API manager detects semantic equality using [drift](https://docs.rs/drift). This was primarily intended so that wire-compatible changes could be made to endpoint handlers for old blessed versions. But, as a side-effect, this property also applies to the latest blessed version. This means that trivial changes to blessed versions can accumulate, up to the point at which a non-trivial change happens, and then the diff becomes quite confusing. Address this by special-casing the latest blessed version, and requiring both bytewise and semantic equality for it. Also allow client-side-versioned APIs (which are currently frozen; see oxidecomputer/omicron#9290) to opt out of this scheme. Closes #35.
1 parent 1f45ec8 commit a8c7dcf

File tree

9 files changed

+397
-26
lines changed

9 files changed

+397
-26
lines changed

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

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,13 @@ pub struct ManagedApiConfig {
4646
pub extra_validation: Option<fn(&OpenAPI, ValidationContext<'_>)>,
4747
}
4848

49-
/// Used internally to describe an API managed by this tool.
49+
/// Describes an API managed by the Dropshot API manager.
50+
///
51+
/// This type is typically created from a [`ManagedApiConfig`] and can be
52+
/// further configured using builder methods before being passed to
53+
/// [`ManagedApis::new`].
5054
#[derive(Debug)]
51-
pub(crate) struct ManagedApi {
55+
pub struct ManagedApi {
5256
/// The API-specific part of the filename that's used for API descriptions
5357
///
5458
/// This string is sometimes used as an identifier for developers.
@@ -73,6 +77,12 @@ pub(crate) struct ManagedApi {
7377

7478
/// Extra validation to perform on the OpenAPI document, if any.
7579
extra_validation: Option<fn(&OpenAPI, ValidationContext<'_>)>,
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,
7686
}
7787

7888
impl From<ManagedApiConfig> for ManagedApi {
@@ -84,46 +94,70 @@ impl From<ManagedApiConfig> for ManagedApi {
8494
metadata: value.metadata,
8595
api_description: value.api_description,
8696
extra_validation: value.extra_validation,
97+
allow_trivial_changes_for_latest: false,
8798
}
8899
}
89100
}
90101

91102
impl ManagedApi {
103+
/// Returns the API identifier.
92104
pub fn ident(&self) -> &ApiIdent {
93105
&self.ident
94106
}
95107

108+
/// Returns the API versions.
96109
pub fn versions(&self) -> &Versions {
97110
&self.versions
98111
}
99112

113+
/// Returns the API title.
100114
pub fn title(&self) -> &'static str {
101115
self.title
102116
}
103117

118+
/// Returns the API metadata.
104119
pub fn metadata(&self) -> &ManagedApiMetadata {
105120
&self.metadata
106121
}
107122

123+
/// Returns true if the API is lockstep.
108124
pub fn is_lockstep(&self) -> bool {
109125
self.versions.is_lockstep()
110126
}
111127

128+
/// Returns true if the API is versioned.
112129
pub fn is_versioned(&self) -> bool {
113130
self.versions.is_versioned()
114131
}
115132

116-
pub fn iter_versioned_versions(
133+
/// Allows trivial changes (doc updates, type renames) for the latest
134+
/// blessed version without requiring a version bump.
135+
///
136+
/// By default, the latest blessed version requires bytewise equality
137+
/// between blessed and generated documents. This prevents trivial changes
138+
/// from accumulating invisibly. Calling this method allows semantic-only
139+
/// checking for all versions, including the latest.
140+
pub fn allow_trivial_changes_for_latest(mut self) -> Self {
141+
self.allow_trivial_changes_for_latest = true;
142+
self
143+
}
144+
145+
/// Returns true if trivial changes are allowed for the latest version.
146+
pub fn allows_trivial_changes_for_latest(&self) -> bool {
147+
self.allow_trivial_changes_for_latest
148+
}
149+
150+
pub(crate) fn iter_versioned_versions(
117151
&self,
118152
) -> Option<impl Iterator<Item = &SupportedVersion> + '_> {
119153
self.versions.iter_versioned_versions()
120154
}
121155

122-
pub fn iter_versions_semver(&self) -> IterVersionsSemvers<'_> {
156+
pub(crate) fn iter_versions_semver(&self) -> IterVersionsSemvers<'_> {
123157
self.versions.iter_versions_semvers()
124158
}
125159

126-
pub fn generate_openapi_doc(
160+
pub(crate) fn generate_openapi_doc(
127161
&self,
128162
version: &semver::Version,
129163
) -> anyhow::Result<OpenAPI> {
@@ -136,7 +170,7 @@ impl ManagedApi {
136170
.context("generated document is not valid OpenAPI")
137171
}
138172

139-
pub fn generate_spec_bytes(
173+
pub(crate) fn generate_spec_bytes(
140174
&self,
141175
version: &semver::Version,
142176
) -> anyhow::Result<Vec<u8>> {
@@ -165,7 +199,7 @@ impl ManagedApi {
165199
Ok(contents)
166200
}
167201

168-
pub fn extra_validation(
202+
pub(crate) fn extra_validation(
169203
&self,
170204
openapi: &OpenAPI,
171205
validation_context: ValidationContext<'_>,
@@ -192,10 +226,16 @@ impl ManagedApis {
192226
/// configurations.
193227
///
194228
/// This is the main entry point for creating a new `ManagedApis` instance.
195-
pub fn new(api_list: Vec<ManagedApiConfig>) -> anyhow::Result<ManagedApis> {
229+
/// Accepts any iterable of items that can be converted into [`ManagedApi`],
230+
/// including `Vec<ManagedApiConfig>` and `Vec<ManagedApi>`.
231+
pub fn new<I>(api_list: I) -> anyhow::Result<ManagedApis>
232+
where
233+
I: IntoIterator,
234+
I::Item: Into<ManagedApi>,
235+
{
196236
let mut apis = BTreeMap::new();
197237
for api in api_list {
198-
let api = ManagedApi::from(api);
238+
let api = api.into();
199239
if let Some(old) = apis.insert(api.ident.clone(), api) {
200240
bail!("API is defined twice: {:?}", &old.ident);
201241
}

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,6 +529,35 @@ pub fn display_resolution_problems<'a, T>(
529529
}
530530
}
531531

532+
// For BlessedLatestVersionBytewiseMismatch, show a diff between blessed
533+
// and generated versions even though there's no fix.
534+
if let Problem::BlessedLatestVersionBytewiseMismatch {
535+
blessed,
536+
generated,
537+
} = p
538+
{
539+
let diff =
540+
TextDiff::from_lines(blessed.contents(), generated.contents());
541+
let path1 =
542+
env.openapi_abs_dir().join(blessed.spec_file_name().path());
543+
let path2 =
544+
env.openapi_abs_dir().join(generated.spec_file_name().path());
545+
let indent = " ".repeat(HEADER_WIDTH + 1);
546+
let _ = write_diff(
547+
&diff,
548+
&path1,
549+
&path2,
550+
styles,
551+
// context_radius: show enough context to understand the changes.
552+
3,
553+
/* missing_newline_hint */ true,
554+
&mut indent_write::io::IndentWriter::new(
555+
&indent,
556+
std::io::stderr(),
557+
),
558+
);
559+
}
560+
532561
let Some(fix) = p.fix() else {
533562
continue;
534563
};

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,20 @@ pub enum Problem<'a> {
176176
)]
177177
BlessedVersionBroken { compatibility_issues: Vec<ApiCompatIssue> },
178178

179+
#[error(
180+
"For the latest blessed version, the OpenAPI document generated from \
181+
the current code is wire-compatible but not bytewise \
182+
identical to the blessed document. This implies one or more \
183+
trivial changes such as type renames or documentation updates. \
184+
To proceed, bump the API version in the `api_versions!` macro; \
185+
unless you're introducing other changes, there's no need to make \
186+
changes to any endpoints."
187+
)]
188+
BlessedLatestVersionBytewiseMismatch {
189+
blessed: &'a BlessedApiSpecFile,
190+
generated: &'a GeneratedApiSpecFile,
191+
},
192+
179193
#[error(
180194
"No local OpenAPI document was found for this lockstep API. This is \
181195
only expected if you're adding a new lockstep API. This tool can \
@@ -281,6 +295,7 @@ impl<'a> Problem<'a> {
281295
}
282296
Problem::BlessedVersionCompareError { .. } => None,
283297
Problem::BlessedVersionBroken { .. } => None,
298+
Problem::BlessedLatestVersionBytewiseMismatch { .. } => None,
284299
Problem::LockstepMissingLocal { generated }
285300
| Problem::LockstepStale { generated, .. } => {
286301
Some(Fix::UpdateLockstepFile { generated })
@@ -945,6 +960,7 @@ fn resolve_api_version_blessed<'a>(
945960
local: &'a [LocalApiSpecFile],
946961
) -> Resolution<'a> {
947962
let mut problems = Vec::new();
963+
let is_latest = version.is_latest;
948964

949965
// Validate the generated API document.
950966
//
@@ -973,6 +989,22 @@ fn resolve_api_version_blessed<'a>(
973989
}
974990
};
975991

992+
// For the latest version, also require bytewise equality. This ensures that
993+
// trivial changes don't accumulate invisibly. If the generated spec is
994+
// semantically equivalent but bytewise different, require a version bump.
995+
//
996+
// This check can be disabled via `allow_trivial_changes_for_latest()`.
997+
if is_latest
998+
&& !api.allows_trivial_changes_for_latest()
999+
&& problems.is_empty()
1000+
&& generated.contents() != blessed.contents()
1001+
{
1002+
problems.push(Problem::BlessedLatestVersionBytewiseMismatch {
1003+
blessed,
1004+
generated,
1005+
});
1006+
}
1007+
9761008
// Now, there should be at least one local spec that exactly matches the
9771009
// blessed one.
9781010
let (matching, non_matching): (Vec<_>, Vec<_>) =

0 commit comments

Comments
 (0)