osd compares two OpenAPI specifications and produces a structured, severity-classified changeset. It supports OpenAPI 3.0.x and 3.1.x in both YAML and JSON, auto-detecting version and format.
Changes are classified into three severity tiers:
| Severity | Meaning |
|---|---|
| Breaking | Consumers will break if they don't adapt |
| Deprecated | Something newly marked deprecated; not broken yet |
| Additive | New capabilities added; no consumer impact |
Requires Rust 1.70+ (2021 edition). Install via rustup:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Then build and install:
cargo install --path .
Download pre-built binaries for your platform from the Releases page.
cargo build --release
The binary is written to target/release/osd.
To run the test suite:
cargo test
osd diff old-spec.yaml new-spec.yaml
Prints a plain-text report to stdout. Exit codes are CI-friendly: 0 = no changes or success, 1 = breaking changes found (check), 2 = error.
Input files can be YAML or JSON in any combination -- format is detected automatically from file content.
Four built-in formats are available: text (default), markdown, json, html.
osd diff old.yaml new.yaml -f markdown
osd diff old.yaml new.yaml -f json
osd diff old.yaml new.yaml -f html
Exit 1 if breaking changes exist, 0 otherwise:
osd check old.yaml new.yaml
Only report changes at or above a given severity:
osd diff old.yaml new.yaml --min-severity breaking
osd diff old.yaml new.yaml --min-severity deprecated
osd diff old.yaml new.yaml -f html -o report.html
osd diff old.yaml new.yaml -t my_template.tera
When -t is provided it overrides -f. See Custom templates below.
Dump a built-in template to disk so you can use it as a starting point:
osd templates text my_text.tera
osd templates markdown my_md.tera
osd templates json my_json.tera
osd templates html my_html.tera
osd <COMMAND>
Commands:
diff Compare two OpenAPI specs and report changes
check Exit 1 if breaking changes are found, 0 otherwise (CI gate)
templates Export a built-in template to a file for customization
help Print this message or the help of the given subcommand(s)
osd diff [OPTIONS] <OLD> <NEW>
Arguments:
<OLD> Path to the old (base) OpenAPI spec
<NEW> Path to the new (changed) OpenAPI spec
Options:
-f, --format <FORMAT> Output format: text, markdown, json, html [default: text]
-t, --template <FILE> Custom Tera template file (overrides --format)
-o, --output <FILE> Write output to file instead of stdout
--min-severity <SEVERITY> Minimum severity: additive, deprecated, breaking [default: additive]
-h, --help Print help
-V, --version Print version
OpenAPI x-* extensions are diffed through a pluggable processor system. By default, all extension changes (added, removed, modified) are classified as Additive. You can register custom processors that apply different severity rules for specific extension keys.
Built-in processor: x-extensible-enum
The x-extensible-enum extension is a common convention for enum values that are expected to grow over time. osd ships a dedicated processor for it:
- Values added to the enum --
Additive - Values removed from the enum --
Breaking - Entire extension added --
Additive - Entire extension removed --
Breaking
Writing a custom processor
Implement the ExtensionProcessor trait from openapi_semantic_diff::extension:
use openapi_semantic_diff::extension::ExtensionProcessor;
use openapi_semantic_diff::model::{Change, ChangeType, Severity};
pub struct MyExtensionProcessor;
impl ExtensionProcessor for MyExtensionProcessor {
/// The extension key this processor handles.
fn key(&self) -> &str {
"x-my-extension"
}
/// Produce changes given old and new values at the given path.
fn process(
&self,
path: &str,
old_value: Option<&serde_json::Value>,
new_value: Option<&serde_json::Value>,
) -> Vec<Change> {
let ext_path = format!("{path}.x-my-extension");
match (old_value, new_value) {
(None, Some(new)) => vec![Change {
path: ext_path,
change_type: ChangeType::Added,
severity: Severity::Additive,
message: "x-my-extension added".into(),
old_value: None,
new_value: Some(new.clone()),
}],
(Some(old), None) => vec![Change {
path: ext_path,
change_type: ChangeType::Removed,
severity: Severity::Breaking,
message: "x-my-extension removed".into(),
old_value: Some(old.clone()),
new_value: None,
}],
(Some(old), Some(new)) if old != new => vec![Change {
path: ext_path,
change_type: ChangeType::Modified,
severity: Severity::Breaking,
message: "x-my-extension changed".into(),
old_value: Some(old.clone()),
new_value: Some(new.clone()),
}],
_ => vec![],
}
}
}Register it on an ExtensionRegistry and pass that to the comparator:
use openapi_semantic_diff::extension::ExtensionRegistry;
use openapi_semantic_diff::comparator::compare_with_extensions;
use openapi_semantic_diff::parser;
let old = parser::parse_file("old.yaml").unwrap();
let new = parser::parse_file("new.yaml").unwrap();
let mut registry = ExtensionRegistry::with_defaults();
registry.register(Box::new(MyExtensionProcessor));
let report = compare_with_extensions(&old, &new, ®istry);ExtensionRegistry::with_defaults() includes the built-in x-extensible-enum processor. Use ExtensionRegistry::new() for an empty registry if you want full control.
Output is rendered through Tera templates (Jinja2-style syntax). The fastest way to get started is to export a built-in template and modify it:
osd templates text my_template.tera
# edit my_template.tera
osd diff old.yaml new.yaml -t my_template.tera
Template context
Every template receives the following variables:
| Variable | Type | Description |
|---|---|---|
changes |
array of Change | All changes (flat list across all categories) |
max_severity |
string or null | Highest severity found ("breaking", "deprecated", "additive", or null) |
info_changes |
array of Change | Changes to info (title, version, description) |
server_changes |
array of Change | Server additions/removals |
path_changes |
array of Change | Endpoint and operation changes |
schema_changes |
array of Change | Component schema changes |
security_scheme_changes |
array of Change | Security scheme changes |
tag_changes |
array of Change | Tag additions/removals |
extension_changes |
array of Change | Top-level x-* extension changes |
Each Change object has these fields:
| Field | Type | Description |
|---|---|---|
path |
string | Dotted path to the changed element (e.g. paths./pets.GET) |
change_type |
string | One of: added, removed, modified, deprecated |
severity |
string | One of: breaking, deprecated, additive |
message |
string | Human-readable description of the change |
old_value |
JSON value or null | The previous value, if applicable |
new_value |
JSON value or null | The new value, if applicable |
Example: minimal custom template
Total changes: {{ changes | length }}
{% for change in changes -%}
[{{ change.severity }}] {{ change.message }}
{% endfor %}
Tera filter reference
Useful built-in Tera filters for templates:
{{ changes | length }}-- array length{{ changes | filter(attribute="severity", value="breaking") }}-- filter by field value{{ value | upper }}-- uppercase a string{{ value | default(value="n/a") }}-- fallback for null values
Full Tera documentation: https://keats.github.io/tera/docs/
See the examples/ directory for sample input specs and outputs in all four formats.
Markdown output showcase (click to expand)
Markdown output (-f markdown) from a showcase diff covering every severity and change type across paths, parameters, and schemas:
39 change(s) detected | Max severity: breaking
-
π΄β parameter 'fields' (query) removed β
parameters.fields.query -
π΄βοΈ parameter 'limit' is now required β
parameters.limit.query -
π‘ parameter 'offset' marked as deprecated β
parameters.offset.query -
π’β parameter 'sort' (query) added β
parameters.sort.query -
π’β property 'avatar_url' added β
responses.200.content.application/json.schema.items.properties.avatar_url -
π’β [schema: User] property 'avatar_url' added β
responses.200.content.application/json.schema.items.properties.avatar_url -
π΄βοΈ maxLength reduced from 200 to 100 β
responses.200.content.application/json.schema.items.properties.email.maxLength -
π΄βοΈ [schema: User] maxLength reduced from 200 to 100 β
responses.200.content.application/json.schema.items.properties.email.maxLength -
π’βοΈ minLength reduced from 5 to 1 β
responses.200.content.application/json.schema.items.properties.email.minLength -
π’βοΈ [schema: User] minLength reduced from 5 to 1 β
responses.200.content.application/json.schema.items.properties.email.minLength -
π΄β property 'name' removed β
responses.200.content.application/json.schema.items.properties.name -
π΄β [schema: User] property 'name' removed β
responses.200.content.application/json.schema.items.properties.name -
π‘ schema marked as deprecated β
responses.200.content.application/json.schema.items.properties.nickname -
π‘ [schema: User] schema marked as deprecated β
responses.200.content.application/json.schema.items.properties.nickname -
π’β enum value "moderator" added β
responses.200.content.application/json.schema.items.properties.role.enum -
π’β [schema: User] enum value "moderator" added β
responses.200.content.application/json.schema.items.properties.role.enum -
π΄β enum value "viewer" removed β
responses.200.content.application/json.schema.items.properties.role.enum -
π΄β [schema: User] enum value "viewer" removed β
responses.200.content.application/json.schema.items.properties.role.enum -
π΄β response '500' removed β
responses.500
-
π’β property 'avatar_url' added β
responses.201.content.application/json.schema.properties.avatar_url -
π’β [schema: User] property 'avatar_url' added β
responses.201.content.application/json.schema.properties.avatar_url -
π΄βοΈ maxLength reduced from 200 to 100 β
responses.201.content.application/json.schema.properties.email.maxLength -
π΄βοΈ [schema: User] maxLength reduced from 200 to 100 β
responses.201.content.application/json.schema.properties.email.maxLength -
π’βοΈ minLength reduced from 5 to 1 β
responses.201.content.application/json.schema.properties.email.minLength -
π’βοΈ [schema: User] minLength reduced from 5 to 1 β
responses.201.content.application/json.schema.properties.email.minLength -
π΄β property 'name' removed β
responses.201.content.application/json.schema.properties.name -
π΄β [schema: User] property 'name' removed β
responses.201.content.application/json.schema.properties.name -
π‘ schema marked as deprecated β
responses.201.content.application/json.schema.properties.nickname -
π‘ [schema: User] schema marked as deprecated β
responses.201.content.application/json.schema.properties.nickname -
π’β enum value "moderator" added β
responses.201.content.application/json.schema.properties.role.enum -
π’β [schema: User] enum value "moderator" added β
responses.201.content.application/json.schema.properties.role.enum -
π΄β enum value "viewer" removed β
responses.201.content.application/json.schema.properties.role.enum -
π΄β [schema: User] enum value "viewer" removed β
responses.201.content.application/json.schema.properties.role.enum
-
π’β property 'avatar_url' added β
responses.200.content.application/json.schema.properties.avatar_url -
π’β [schema: User] property 'avatar_url' added β
responses.200.content.application/json.schema.properties.avatar_url -
π΄βοΈ maxLength reduced from 200 to 100 β
responses.200.content.application/json.schema.properties.email.maxLength -
π΄βοΈ [schema: User] maxLength reduced from 200 to 100 β
responses.200.content.application/json.schema.properties.email.maxLength -
π’βοΈ minLength reduced from 5 to 1 β
responses.200.content.application/json.schema.properties.email.minLength -
π’βοΈ [schema: User] minLength reduced from 5 to 1 β
responses.200.content.application/json.schema.properties.email.minLength -
π΄β property 'name' removed β
responses.200.content.application/json.schema.properties.name -
π΄β [schema: User] property 'name' removed β
responses.200.content.application/json.schema.properties.name -
π‘ schema marked as deprecated β
responses.200.content.application/json.schema.properties.nickname -
π‘ [schema: User] schema marked as deprecated β
responses.200.content.application/json.schema.properties.nickname -
π’β enum value "moderator" added β
responses.200.content.application/json.schema.properties.role.enum -
π’β [schema: User] enum value "moderator" added β
responses.200.content.application/json.schema.properties.role.enum -
π΄β enum value "viewer" removed β
responses.200.content.application/json.schema.properties.role.enum -
π΄β [schema: User] enum value "viewer" removed β
responses.200.content.application/json.schema.properties.role.enum
- π‘ operation marked as deprecated
- π΄β endpoint PUT /users/{userId}/avatar removed
- π’β endpoint GET /users/{userId}/settings added
- π’βοΈ version changed from '1.0.0' to '2.0.0' β
version
- π΄β schema 'LegacyProfile' removed β
components.schemas.LegacyProfile
- π’β schema 'Settings' added β
components.schemas.Settings
See CONTRIBUTING.md for development setup and guidelines.