Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 49 additions & 9 deletions crates/dropshot-api-manager/src/apis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,13 @@ pub struct ManagedApiConfig {
pub extra_validation: Option<fn(&OpenAPI, ValidationContext<'_>)>,
}

/// Used internally to describe an API managed by this tool.
/// 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`].
#[derive(Debug)]
pub(crate) struct ManagedApi {
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.
Expand All @@ -73,6 +77,12 @@ pub(crate) struct ManagedApi {

/// Extra validation to perform on the OpenAPI document, if any.
extra_validation: Option<fn(&OpenAPI, ValidationContext<'_>)>,

/// 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,
}

impl From<ManagedApiConfig> for ManagedApi {
Expand All @@ -84,46 +94,70 @@ impl From<ManagedApiConfig> for ManagedApi {
metadata: value.metadata,
api_description: value.api_description,
extra_validation: value.extra_validation,
allow_trivial_changes_for_latest: false,
}
}
}

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()
}

pub fn iter_versioned_versions(
/// 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
}

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

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

pub fn generate_openapi_doc(
pub(crate) fn generate_openapi_doc(
&self,
version: &semver::Version,
) -> anyhow::Result<OpenAPI> {
Expand All @@ -136,7 +170,7 @@ impl ManagedApi {
.context("generated document is not valid OpenAPI")
}

pub fn generate_spec_bytes(
pub(crate) fn generate_spec_bytes(
&self,
version: &semver::Version,
) -> anyhow::Result<Vec<u8>> {
Expand Down Expand Up @@ -165,7 +199,7 @@ impl ManagedApi {
Ok(contents)
}

pub fn extra_validation(
pub(crate) fn extra_validation(
&self,
openapi: &OpenAPI,
validation_context: ValidationContext<'_>,
Expand All @@ -192,10 +226,16 @@ impl ManagedApis {
/// configurations.
///
/// This is the main entry point for creating a new `ManagedApis` instance.
pub fn new(api_list: Vec<ManagedApiConfig>) -> anyhow::Result<ManagedApis> {
/// 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 = ManagedApi::from(api);
let api = api.into();
if let Some(old) = apis.insert(api.ident.clone(), api) {
bail!("API is defined twice: {:?}", &old.ident);
}
Expand Down
29 changes: 29 additions & 0 deletions crates/dropshot-api-manager/src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,35 @@ pub fn display_resolution_problems<'a, T>(
}
}

// For BlessedLatestVersionBytewiseMismatch, show a diff between blessed
// and generated versions even though there's no fix.
if let Problem::BlessedLatestVersionBytewiseMismatch {
blessed,
generated,
} = p
{
let diff =
TextDiff::from_lines(blessed.contents(), generated.contents());
let path1 =
env.openapi_abs_dir().join(blessed.spec_file_name().path());
let path2 =
env.openapi_abs_dir().join(generated.spec_file_name().path());
let indent = " ".repeat(HEADER_WIDTH + 1);
let _ = write_diff(
&diff,
&path1,
&path2,
styles,
// context_radius: show enough context to understand the changes.
3,
/* missing_newline_hint */ true,
&mut indent_write::io::IndentWriter::new(
&indent,
std::io::stderr(),
),
);
}

let Some(fix) = p.fix() else {
continue;
};
Expand Down
32 changes: 32 additions & 0 deletions crates/dropshot-api-manager/src/resolved.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,20 @@ pub enum Problem<'a> {
)]
BlessedVersionBroken { compatibility_issues: Vec<ApiCompatIssue> },

#[error(
"For the latest blessed version, the OpenAPI document generated from \
the current code is wire-compatible but not bytewise \
identical to the blessed document. This implies one or more \
trivial changes such as type renames or documentation updates. \
To proceed, bump the API version in the `api_versions!` macro; \
unless you're introducing other changes, there's no need to make \
changes to any endpoints."
)]
BlessedLatestVersionBytewiseMismatch {
blessed: &'a BlessedApiSpecFile,
generated: &'a GeneratedApiSpecFile,
},

#[error(
"No local OpenAPI document was found for this lockstep API. This is \
only expected if you're adding a new lockstep API. This tool can \
Expand Down Expand Up @@ -281,6 +295,7 @@ impl<'a> Problem<'a> {
}
Problem::BlessedVersionCompareError { .. } => None,
Problem::BlessedVersionBroken { .. } => None,
Problem::BlessedLatestVersionBytewiseMismatch { .. } => None,
Problem::LockstepMissingLocal { generated }
| Problem::LockstepStale { generated, .. } => {
Some(Fix::UpdateLockstepFile { generated })
Expand Down Expand Up @@ -945,6 +960,7 @@ fn resolve_api_version_blessed<'a>(
local: &'a [LocalApiSpecFile],
) -> Resolution<'a> {
let mut problems = Vec::new();
let is_latest = version.is_latest;

// Validate the generated API document.
//
Expand Down Expand Up @@ -973,6 +989,22 @@ fn resolve_api_version_blessed<'a>(
}
};

// For the latest version, also require bytewise equality. This ensures that
// trivial changes don't accumulate invisibly. If the generated spec is
// semantically equivalent but bytewise different, require a version bump.
//
// This check can be disabled via `allow_trivial_changes_for_latest()`.
if is_latest
&& !api.allows_trivial_changes_for_latest()
&& problems.is_empty()
&& generated.contents() != blessed.contents()
{
problems.push(Problem::BlessedLatestVersionBytewiseMismatch {
blessed,
generated,
});
}

// Now, there should be at least one local spec that exactly matches the
// blessed one.
let (matching, non_matching): (Vec<_>, Vec<_>) =
Expand Down
Loading