configProps) {
+ OpenApiDiffOptions.Builder builder = OpenApiDiffOptions.builder();
+ if (configFiles != null) {
+ configFiles.forEach(builder::configYaml);
+ }
+ if (configProps != null) {
+ configProps.forEach(builder::configProperty);
+ }
+ return builder.build();
+ }
+}
diff --git a/maven/src/main/java/org/openapitools/openapidiff/maven/OpenApiDiffReport.java b/maven/src/main/java/org/openapitools/openapidiff/maven/OpenApiDiffReport.java
new file mode 100644
index 00000000..326ac5ff
--- /dev/null
+++ b/maven/src/main/java/org/openapitools/openapidiff/maven/OpenApiDiffReport.java
@@ -0,0 +1,162 @@
+package org.openapitools.openapidiff.maven;
+
+import java.io.File;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.nio.file.InvalidPathException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import org.apache.maven.plugins.annotations.LifecyclePhase;
+import org.apache.maven.plugins.annotations.Mojo;
+import org.apache.maven.plugins.annotations.Parameter;
+import org.apache.maven.project.MavenProject;
+import org.apache.maven.reporting.AbstractMavenReport;
+import org.apache.maven.reporting.MavenReportException;
+import org.openapitools.openapidiff.core.OpenApiCompare;
+import org.openapitools.openapidiff.core.model.ChangedOpenApi;
+
+/**
+ * Adds an "OpenAPI Diff" page to the Maven site, listing the differences between two OpenAPI
+ * specifications and whether they are backward compatible.
+ *
+ * Configure the plugin in the {@code } section of the POM to get the page under
+ * "Project Reports" when running {@code mvn site}. Unlike the {@code diff} goal, this goal never
+ * fails the build because of API changes: it only documents them.
+ */
+@Mojo(name = "report", defaultPhase = LifecyclePhase.SITE)
+public class OpenApiDiffReport extends AbstractMavenReport {
+
+ /** Location of the reference specification (file path or URL), e.g. the released API. */
+ @Parameter(property = "oldSpec")
+ String oldSpec;
+
+ /** Location of the specification to compare with {@code oldSpec} (file path or URL). */
+ @Parameter(property = "newSpec")
+ String newSpec;
+
+ /** Config files to override the default incompatibility checks. Later files win. */
+ @Parameter(property = "configFiles")
+ List configFiles;
+
+ /** Config properties to override the default incompatibility checks and config files. */
+ @Parameter(property = "configProps")
+ Map configProps;
+
+ /** Skips the report. */
+ @Parameter(property = "skip", defaultValue = "false")
+ Boolean skip = false;
+
+ @Override
+ public String getOutputName() {
+ return "openapi-diff";
+ }
+
+ @Override
+ public String getName(Locale locale) {
+ return OpenApiDiffReportRenderer.TITLE;
+ }
+
+ @Override
+ public String getDescription(Locale locale) {
+ return "Changes between two OpenAPI specifications and whether they are backward compatible.";
+ }
+
+ @Override
+ public boolean canGenerateReport() {
+ if (Boolean.TRUE.equals(skip)) {
+ getLog().info("Skipping openapi-diff report");
+ return false;
+ }
+ if (isBlank(oldSpec) || isBlank(newSpec)) {
+ getLog().warn("Skipping openapi-diff report: both oldSpec and newSpec must be configured");
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ protected void executeReport(Locale locale) throws MavenReportException {
+ final ChangedOpenApi diff;
+ try {
+ diff =
+ OpenApiCompare.fromLocations(
+ oldSpec, newSpec, null, OpenApiDiffOptionsFactory.create(configFiles, configProps));
+ } catch (RuntimeException e) {
+ throw new MavenReportException(
+ String.format(
+ "Unable to compare %s with %s: %s. If a specification is generated during the"
+ + " build, generate the site with 'mvn verify site'.",
+ displayLocation(oldSpec), displayLocation(newSpec), e.getMessage()),
+ e);
+ }
+ new OpenApiDiffReportRenderer(
+ getSink(), diff, displayLocation(oldSpec), displayLocation(newSpec))
+ .render();
+ }
+
+ /**
+ * Returns the location to show on the page without exposing the build machine or secrets. Files,
+ * including {@code file:} URIs, are shown relative to the project directory, or by name when they
+ * are outside of it. URLs are shown without user info, query and fragment, which may hold
+ * credentials.
+ */
+ String displayLocation(String location) {
+ URI uri = toUri(location);
+ if (uri != null && "file".equalsIgnoreCase(uri.getScheme())) {
+ try {
+ return displayPath(Paths.get(uri));
+ } catch (IllegalArgumentException e) {
+ // e.g. an opaque URI such as file:relative.yaml
+ return lastSegment(location);
+ }
+ }
+ if (uri != null && uri.getHost() != null) {
+ String port = uri.getPort() != -1 ? ":" + uri.getPort() : "";
+ String path = uri.getRawPath() != null ? uri.getRawPath() : "";
+ return uri.getScheme() + "://" + uri.getHost() + port + path;
+ }
+ try {
+ return displayPath(Paths.get(location));
+ } catch (InvalidPathException e) {
+ return lastSegment(location);
+ }
+ }
+
+ private String displayPath(Path location) {
+ Path path = location.toAbsolutePath().normalize();
+ MavenProject project = getProject();
+ if (project != null && project.getBasedir() != null) {
+ Path basedir = project.getBasedir().toPath().toAbsolutePath().normalize();
+ if (path.startsWith(basedir)) {
+ return basedir.relativize(path).toString().replace(File.separatorChar, '/');
+ }
+ }
+ Path fileName = path.getFileName();
+ return fileName != null ? fileName.toString() : "";
+ }
+
+ /** Parses a location as a URI with a scheme, or returns {@code null} for plain paths. */
+ private static URI toUri(String location) {
+ try {
+ URI uri = new URI(location);
+ // a one-letter scheme is a Windows drive, as in C:/specs/openapi.yaml
+ return uri.getScheme() != null && uri.getScheme().length() > 1 ? uri : null;
+ } catch (URISyntaxException e) {
+ return null;
+ }
+ }
+
+ /** Returns the part after the last slash, without query and fragment. */
+ private static String lastSegment(String location) {
+ String withoutQuery = location.split("[?#]", 2)[0];
+ int separator = Math.max(withoutQuery.lastIndexOf('/'), withoutQuery.lastIndexOf('\\'));
+ return withoutQuery.substring(separator + 1);
+ }
+
+ private static boolean isBlank(String value) {
+ return value == null || value.trim().isEmpty();
+ }
+}
diff --git a/maven/src/main/java/org/openapitools/openapidiff/maven/OpenApiDiffReportRenderer.java b/maven/src/main/java/org/openapitools/openapidiff/maven/OpenApiDiffReportRenderer.java
new file mode 100644
index 00000000..1e0a52e1
--- /dev/null
+++ b/maven/src/main/java/org/openapitools/openapidiff/maven/OpenApiDiffReportRenderer.java
@@ -0,0 +1,577 @@
+package org.openapitools.openapidiff.maven;
+
+import static org.openapitools.openapidiff.core.model.BackwardIncompatibleProp.REQUEST_PARAMS_DECREASED;
+import static org.openapitools.openapidiff.core.model.BackwardIncompatibleProp.REQUEST_PARAMS_REQUIRED_INCREASED;
+
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.Operation;
+import io.swagger.v3.oas.models.info.Info;
+import io.swagger.v3.oas.models.media.ArraySchema;
+import io.swagger.v3.oas.models.media.Schema;
+import io.swagger.v3.oas.models.parameters.Parameter;
+import io.swagger.v3.oas.models.parameters.RequestBody;
+import io.swagger.v3.oas.models.security.SecurityRequirement;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import org.apache.maven.doxia.sink.Sink;
+import org.apache.maven.doxia.sink.impl.SinkEventAttributeSet;
+import org.apache.maven.reporting.AbstractMavenReportRenderer;
+import org.openapitools.openapidiff.core.model.Changed;
+import org.openapitools.openapidiff.core.model.ChangedApiResponse;
+import org.openapitools.openapidiff.core.model.ChangedContent;
+import org.openapitools.openapidiff.core.model.ChangedHeaders;
+import org.openapitools.openapidiff.core.model.ChangedOpenApi;
+import org.openapitools.openapidiff.core.model.ChangedOperation;
+import org.openapitools.openapidiff.core.model.ChangedParameter;
+import org.openapitools.openapidiff.core.model.ChangedParameters;
+import org.openapitools.openapidiff.core.model.ChangedRequestBody;
+import org.openapitools.openapidiff.core.model.ChangedSchema;
+import org.openapitools.openapidiff.core.model.ChangedSecurityRequirements;
+import org.openapitools.openapidiff.core.model.DiffContext;
+import org.openapitools.openapidiff.core.model.DiffResult;
+import org.openapitools.openapidiff.core.model.Endpoint;
+import org.openapitools.openapidiff.core.model.schema.ChangedEnum;
+import org.openapitools.openapidiff.core.model.schema.ChangedOperationId;
+import org.openapitools.openapidiff.core.model.schema.ChangedRequired;
+import org.openapitools.openapidiff.core.output.HttpStatus;
+import org.openapitools.openapidiff.core.utils.EndpointUtils;
+
+/**
+ * Writes a {@link ChangedOpenApi} as a Maven site page using the Doxia {@link Sink} API.
+ *
+ * The page follows the structure of the other renderers (What's New, What's Deleted, What's
+ * Deprecated, What's Changed) and starts with a summary of the overall result. Everything is
+ * written as text, so the sink escapes summaries and names coming from the specifications.
+ */
+class OpenApiDiffReportRenderer extends AbstractMavenReportRenderer {
+
+ static final String TITLE = "OpenAPI Diff";
+
+ static final String BREAKS_COMPATIBILITY = " (breaks backward compatibility)";
+
+ /** Guards the listing of schema incompatibilities against unexpectedly deep schemas. */
+ private static final int MAX_SCHEMA_DEPTH = 32;
+
+ private final ChangedOpenApi diff;
+ private final String oldSpecLocation;
+ private final String newSpecLocation;
+
+ OpenApiDiffReportRenderer(
+ Sink sink, ChangedOpenApi diff, String oldSpecLocation, String newSpecLocation) {
+ super(sink);
+ this.diff = diff;
+ this.oldSpecLocation = oldSpecLocation;
+ this.newSpecLocation = newSpecLocation;
+ }
+
+ @Override
+ public String getTitle() {
+ return TITLE;
+ }
+
+ @Override
+ protected void renderBody() {
+ startSection(getTitle());
+ paragraph(
+ "Differences between two versions of the OpenAPI specification, and whether clients"
+ + " written for the old version keep working with the new one.");
+ renderSummary();
+ renderEndpoints("What's New", diff.getNewEndpoints());
+ renderEndpoints("What's Deleted", diff.getMissingEndpoints());
+ renderEndpoints("What's Deprecated", deprecatedEndpoints(diff));
+ renderChangedOperations(diff.getChangedOperations());
+ endSection();
+ }
+
+ /**
+ * Returns the endpoints that the new specification deprecates. {@link
+ * ChangedOpenApi#getDeprecatedEndpoints()} lists every change of the deprecated flag, including
+ * operations that are no longer deprecated, so those are left out here.
+ */
+ static List deprecatedEndpoints(ChangedOpenApi diff) {
+ List endpoints = new ArrayList<>();
+ for (ChangedOperation operation : nullToEmpty(diff.getChangedOperations())) {
+ if (operation.isDeprecated() && isDeprecated(operation.getNewOperation())) {
+ endpoints.add(
+ EndpointUtils.convert2Endpoint(
+ operation.getPathUrl(), operation.getHttpMethod(), operation.getNewOperation()));
+ }
+ }
+ return endpoints;
+ }
+
+ /** Same wording as the console and Markdown renderers. */
+ static String verdict(ChangedOpenApi diff) {
+ if (diff.isUnchanged()) {
+ return "No differences. Specifications are equivalent";
+ }
+ return diff.isCompatible()
+ ? "API changes are backward compatible"
+ : "API changes broke backward compatibility";
+ }
+
+ static String describe(DiffResult result) {
+ if (result.isUnchanged()) {
+ return "No changes";
+ }
+ if (result.isMetaChanged()) {
+ return "Metadata changes only";
+ }
+ return result.isCompatible() ? "Backward compatible" : "Breaks backward compatibility";
+ }
+
+ private void renderSummary() {
+ startTable();
+ summaryRow("Old specification", describe(diff.getOldSpecOpenApi(), oldSpecLocation), false);
+ summaryRow("New specification", describe(diff.getNewSpecOpenApi(), newSpecLocation), false);
+ summaryRow("Result", verdict(diff), true);
+ summaryRow("New endpoints", String.valueOf(size(diff.getNewEndpoints())), false);
+ summaryRow("Deleted endpoints", String.valueOf(size(diff.getMissingEndpoints())), false);
+ summaryRow("Deprecated endpoints", String.valueOf(size(deprecatedEndpoints(diff))), false);
+ summaryRow("Changed endpoints", String.valueOf(size(diff.getChangedOperations())), false);
+ endTable();
+ }
+
+ private void summaryRow(String name, String value, boolean strong) {
+ sink.tableRow();
+ sink.tableHeaderCell();
+ sink.text(name);
+ sink.tableHeaderCell_();
+ sink.tableCell();
+ if (strong) {
+ sink.bold();
+ }
+ sink.text(value);
+ if (strong) {
+ sink.bold_();
+ }
+ sink.tableCell_();
+ sink.tableRow_();
+ }
+
+ private void renderEndpoints(String title, List endpoints) {
+ if (endpoints == null || endpoints.isEmpty()) {
+ return;
+ }
+ startSection(title);
+ startTable();
+ tableHeader(new String[] {"Method", "Path", "Summary"});
+ for (Endpoint endpoint : endpoints) {
+ sink.tableRow();
+ textCell(String.valueOf(endpoint.getMethod()));
+ codeCell(endpoint.getPathUrl());
+ textCell(endpoint.getSummary());
+ sink.tableRow_();
+ }
+ endTable();
+ endSection();
+ }
+
+ private void renderChangedOperations(List operations) {
+ if (operations == null || operations.isEmpty()) {
+ return;
+ }
+ startSection("What's Changed");
+ for (ChangedOperation operation : operations) {
+ startSection(operation.getHttpMethod() + " " + operation.getPathUrl());
+ String summary = summaryOf(operation);
+ if (summary != null && !summary.trim().isEmpty()) {
+ paragraph(summary);
+ }
+ sink.paragraph();
+ sink.bold();
+ sink.text("Compatibility:");
+ sink.bold_();
+ sink.text(" " + describe(operation.isChanged()));
+ sink.paragraph_();
+ renderLines(describeChanges(operation));
+ endSection();
+ }
+ endSection();
+ }
+
+ private void renderLines(List lines) {
+ if (lines.isEmpty()) {
+ return;
+ }
+ sink.list();
+ for (Line line : lines) {
+ sink.listItem();
+ sink.text(line.text);
+ renderLines(line.children);
+ sink.listItem_();
+ }
+ sink.list_();
+ }
+
+ /** Lists what changed in an operation, grouped like the console renderer. */
+ List describeChanges(ChangedOperation operation) {
+ List lines = new ArrayList<>();
+ if (operation.isDeprecated()) {
+ boolean wasDeprecated = isDeprecated(operation.getOldOperation());
+ boolean isDeprecated = isDeprecated(operation.getNewOperation());
+ if (isDeprecated && !wasDeprecated) {
+ lines.add(new Line("Operation deprecated"));
+ } else if (wasDeprecated && !isDeprecated) {
+ lines.add(new Line("Operation no longer deprecated"));
+ }
+ }
+ ChangedOperationId operationId = operation.getOperationId();
+ if (operationId != null && operationId.isDifferent()) {
+ lines.add(
+ new Line(
+ "Operation ID changed from "
+ + valueOrNone(operationId.getLeft())
+ + " to "
+ + valueOrNone(operationId.getRight())));
+ }
+ addGroup(lines, parameters(operation.getParameters()));
+ addGroup(lines, requestBody(operation.getRequestBody()));
+ addGroup(lines, responses(operation.getApiResponses()));
+ addGroup(lines, security(operation.getSecurityRequirements()));
+ return lines;
+ }
+
+ private static void addGroup(List lines, Line group) {
+ if (!group.children.isEmpty()) {
+ lines.add(group);
+ }
+ }
+
+ private static Line parameters(ChangedParameters parameters) {
+ Line group = new Line("Parameters");
+ if (parameters == null) {
+ return group;
+ }
+ // same rules as ChangedParameters#isCoreChanged
+ DiffContext context = parameters.getContext();
+ boolean requiredIncreasedBreaks = REQUEST_PARAMS_REQUIRED_INCREASED.enabled(context);
+ for (Parameter parameter : nullToEmpty(parameters.getIncreased())) {
+ boolean breaks = requiredIncreasedBreaks && Boolean.TRUE.equals(parameter.getRequired());
+ group.add("Added " + describe(parameter) + marker(breaks));
+ }
+ for (ChangedParameter parameter : nullToEmpty(parameters.getChanged())) {
+ Parameter newParameter = parameter.getNewParameter();
+ boolean deprecated =
+ parameter.isDeprecated() && Boolean.TRUE.equals(newParameter.getDeprecated());
+ String text = (deprecated ? "Deprecated " : "Changed ") + describe(newParameter);
+ group.add(parameter.isIncompatible() ? text + BREAKS_COMPATIBILITY : text);
+ }
+ boolean decreasedBreaks = REQUEST_PARAMS_DECREASED.enabled(context);
+ for (Parameter parameter : nullToEmpty(parameters.getMissing())) {
+ group.add("Deleted " + describe(parameter) + marker(decreasedBreaks));
+ }
+ return group;
+ }
+
+ private Line requestBody(ChangedRequestBody requestBody) {
+ Line group = new Line("Request body");
+ if (requestBody == null) {
+ return group;
+ }
+ RequestBody newRequestBody = requestBody.getNewRequestBody();
+ if (requestBody.isChangeRequired() && newRequestBody != null) {
+ boolean required = Boolean.TRUE.equals(newRequestBody.getRequired());
+ String text = required ? "Request body is now required" : "Request body is now optional";
+ // only the required flag is evaluated by the core result, not the content
+ group.add(requestBody.isCoreChanged().isIncompatible() ? text + BREAKS_COMPATIBILITY : text);
+ }
+ content(group, requestBody.getContent());
+ return group;
+ }
+
+ private Line responses(ChangedApiResponse responses) {
+ Line group = new Line("Responses");
+ if (responses == null) {
+ return group;
+ }
+ for (String code : nullToEmpty(responses.getIncreased()).keySet()) {
+ group.add("Added " + describeStatus(code));
+ }
+ // deleted responses are the only reason for an incompatible core result
+ boolean deletionsBreak = responses.isCoreChanged().isIncompatible();
+ for (String code : nullToEmpty(responses.getMissing()).keySet()) {
+ group.add("Deleted " + describeStatus(code) + marker(deletionsBreak));
+ }
+ nullToEmpty(responses.getChanged())
+ .forEach(
+ (code, response) -> {
+ Line line = group.add("Changed " + describeStatus(code));
+ if (response != null) {
+ content(line, response.getContent());
+ headers(line, response.getHeaders());
+ }
+ });
+ return group;
+ }
+
+ private void content(Line group, ChangedContent content) {
+ if (content == null) {
+ return;
+ }
+ for (String mediaType : nullToEmpty(content.getIncreased()).keySet()) {
+ group.add("Added " + mediaType);
+ }
+ // deleted media types are the only reason for an incompatible core result
+ boolean deletionsBreak = content.isCoreChanged().isIncompatible();
+ for (String mediaType : nullToEmpty(content.getMissing()).keySet()) {
+ group.add("Deleted " + mediaType + marker(deletionsBreak));
+ }
+ nullToEmpty(content.getChanged())
+ .forEach(
+ (mediaType, changedMediaType) -> {
+ if (!changedMediaType.isCompatible()) {
+ Line line = group.add("Changed " + mediaType + ": schema breaks compatibility");
+ incompatibilities(line, "", changedMediaType.getSchema(), 0);
+ } else if (changedMediaType.getSchema() != null) {
+ group.add("Changed " + mediaType + ": schema is backward compatible");
+ } else {
+ group.add("Changed " + mediaType + ": examples only");
+ }
+ });
+ }
+
+ private static void headers(Line group, ChangedHeaders headers) {
+ if (headers == null) {
+ return;
+ }
+ for (String name : nullToEmpty(headers.getIncreased()).keySet()) {
+ group.add("Added header " + name);
+ }
+ // deleted headers are the only reason for an incompatible core result
+ boolean deletionsBreak = headers.isCoreChanged().isIncompatible();
+ for (String name : nullToEmpty(headers.getMissing()).keySet()) {
+ group.add("Deleted header " + name + marker(deletionsBreak));
+ }
+ nullToEmpty(headers.getChanged())
+ .forEach(
+ (name, header) ->
+ group.add(
+ "Changed header "
+ + name
+ + (header.isIncompatible() ? BREAKS_COMPATIBILITY : "")));
+ }
+
+ private static Line security(ChangedSecurityRequirements security) {
+ Line group = new Line("Security requirements");
+ if (security == null) {
+ return group;
+ }
+ for (SecurityRequirement requirement : nullToEmpty(security.getIncreased())) {
+ group.add("Added " + describe(requirement));
+ }
+ // deleted requirements are the only reason for an incompatible core result
+ boolean deletionsBreak = security.isCoreChanged().isIncompatible();
+ for (SecurityRequirement requirement : nullToEmpty(security.getMissing())) {
+ group.add("Deleted " + describe(requirement) + marker(deletionsBreak));
+ }
+ nullToEmpty(security.getChanged())
+ .forEach(
+ requirement ->
+ group.add(
+ "Changed "
+ + describe(requirement.getOldSecurityRequirement())
+ + (requirement.isIncompatible() ? BREAKS_COMPATIBILITY : "")));
+ return group;
+ }
+
+ /** Explains why a schema breaks compatibility, like the console renderer does. */
+ private static void incompatibilities(Line parent, String path, ChangedSchema schema, int depth) {
+ if (schema == null || depth > MAX_SCHEMA_DEPTH) {
+ return;
+ }
+ String name = path.isEmpty() ? "schema" : path;
+ if (schema.isCoreChanged() == DiffResult.INCOMPATIBLE && schema.isChangedType()) {
+ parent.add(
+ "Changed type of "
+ + name
+ + ": "
+ + type(schema.getOldSchema())
+ + " -> "
+ + type(schema.getNewSchema()));
+ }
+ if (schema.isCoreChanged() == DiffResult.INCOMPATIBLE
+ && schema.isDiscriminatorPropertyChanged()) {
+ parent.add("Changed discriminator of " + name);
+ }
+ // constraints that can break compatibility on their own
+ changedIfIncompatible(parent, schema.getMaxLength(), "maxLength", name);
+ changedIfIncompatible(parent, schema.getMinLength(), "minLength", name);
+ changedIfIncompatible(parent, schema.getNumericRange(), "numeric range", name);
+ changedIfIncompatible(parent, schema.getMultipleOf(), "multipleOf", name);
+ changedIfIncompatible(parent, schema.getPattern(), "pattern", name);
+ changedIfIncompatible(parent, schema.getMaxItems(), "maxItems", name);
+ changedIfIncompatible(parent, schema.getMinItems(), "minItems", name);
+ changedIfIncompatible(parent, schema.getUniqueItems(), "uniqueItems", name);
+ changedIfIncompatible(parent, schema.getMaxProperties(), "maxProperties", name);
+ changedIfIncompatible(parent, schema.getMinProperties(), "minProperties", name);
+ changedIfIncompatible(parent, schema.getReadOnly(), "readOnly", name);
+ changedIfIncompatible(parent, schema.getWriteOnly(), "writeOnly", name);
+ changedIfIncompatible(parent, schema.getOneOfSchema(), "oneOf alternatives", name);
+ changedIfIncompatible(parent, schema.getAddProp(), "additionalProperties", name);
+ ChangedRequired required = schema.getRequired();
+ if (required != null && required.isIncompatible()) {
+ for (String property : required.getIncreased()) {
+ parent.add("New required property " + join(path, property));
+ }
+ for (String property : required.getMissing()) {
+ parent.add("Property " + join(path, property) + " is no longer required");
+ }
+ }
+ ChangedEnum> enumeration = schema.getEnumeration();
+ if (enumeration != null && enumeration.isIncompatible()) {
+ if (!enumeration.getMissing().isEmpty()) {
+ parent.add("Removed values of " + name + ": " + enumeration.getMissing());
+ }
+ if (!enumeration.getIncreased().isEmpty()) {
+ parent.add("Added values of " + name + ": " + enumeration.getIncreased());
+ }
+ }
+ schema
+ .getMissingProperties()
+ .forEach(
+ (property, propertySchema) ->
+ parent.add(
+ "Removed property "
+ + join(path, property)
+ + " ("
+ + type(propertySchema)
+ + ")"));
+ if (schema.getItems() != null) {
+ incompatibilities(parent, path + "[n]", schema.getItems(), depth + 1);
+ }
+ schema
+ .getChangedProperties()
+ .forEach(
+ (property, changed) ->
+ incompatibilities(parent, join(path, property), changed, depth + 1));
+ }
+
+ private static String marker(boolean breaksCompatibility) {
+ return breaksCompatibility ? BREAKS_COMPATIBILITY : "";
+ }
+
+ private static boolean isDeprecated(Operation operation) {
+ return operation != null && Boolean.TRUE.equals(operation.getDeprecated());
+ }
+
+ private static void changedIfIncompatible(
+ Line parent, Changed changed, String constraint, String name) {
+ if (changed != null && changed.isIncompatible()) {
+ parent.add("Changed " + constraint + " of " + name);
+ }
+ }
+
+ private void textCell(String text) {
+ sink.tableCell();
+ if (text != null) {
+ sink.text(text);
+ }
+ sink.tableCell_();
+ }
+
+ private void codeCell(String text) {
+ sink.tableCell();
+ sink.inline(SinkEventAttributeSet.Semantics.CODE);
+ sink.text(text);
+ sink.inline_();
+ sink.tableCell_();
+ }
+
+ private static String describe(OpenAPI spec, String location) {
+ Info info = spec != null ? spec.getInfo() : null;
+ StringBuilder description = new StringBuilder();
+ if (info != null && info.getTitle() != null) {
+ description.append(info.getTitle());
+ }
+ if (info != null && info.getVersion() != null) {
+ description.append(description.length() > 0 ? " " : "").append(info.getVersion());
+ }
+ if (location != null) {
+ description.append(description.length() > 0 ? " (" + location + ")" : location);
+ }
+ return description.toString();
+ }
+
+ private static String describe(Parameter parameter) {
+ String name = parameter.getName() != null ? parameter.getName() : parameter.get$ref();
+ return name + " in " + parameter.getIn();
+ }
+
+ private static String describe(SecurityRequirement requirement) {
+ if (requirement == null || requirement.isEmpty()) {
+ return "anonymous access";
+ }
+ return "requirement " + String.join(", ", requirement.keySet());
+ }
+
+ static String describeStatus(String code) {
+ try {
+ String reason = HttpStatus.getReasonPhrase(Integer.parseInt(code));
+ return reason != null ? code + " " + reason : code;
+ } catch (NumberFormatException e) {
+ // "default" and ranges such as "2XX" have no reason phrase
+ return code;
+ }
+ }
+
+ private static String summaryOf(ChangedOperation operation) {
+ Operation newOperation = operation.getNewOperation();
+ Operation operationWithSummary =
+ newOperation != null ? newOperation : operation.getOldOperation();
+ return operationWithSummary != null ? operationWithSummary.getSummary() : null;
+ }
+
+ private static String type(Schema> schema) {
+ if (schema == null) {
+ return "no schema";
+ }
+ if (schema.get$ref() != null) {
+ return schema.get$ref().substring(schema.get$ref().lastIndexOf('/') + 1);
+ }
+ String type = schema instanceof ArraySchema ? "array" : schema.getType();
+ if (type == null) {
+ type = "object";
+ }
+ return schema.getFormat() != null ? type + " (" + schema.getFormat() + ")" : type;
+ }
+
+ private static String join(String path, String property) {
+ return path.isEmpty() ? property : path + "." + property;
+ }
+
+ private static String valueOrNone(String value) {
+ return value != null ? value : "none";
+ }
+
+ private static int size(Collection> values) {
+ return values != null ? values.size() : 0;
+ }
+
+ private static List nullToEmpty(List values) {
+ return values != null ? values : Collections.emptyList();
+ }
+
+ private static Map nullToEmpty(Map values) {
+ return values != null ? values : Collections.emptyMap();
+ }
+
+ /** One entry of the nested list of changes of an operation. */
+ static final class Line {
+ final String text;
+ final List children = new ArrayList<>();
+
+ Line(String text) {
+ this.text = text;
+ }
+
+ Line add(String childText) {
+ Line child = new Line(childText);
+ children.add(child);
+ return child;
+ }
+ }
+}
diff --git a/maven/src/test/java/org/openapitools/openapidiff/maven/OpenApiDiffReportTest.java b/maven/src/test/java/org/openapitools/openapidiff/maven/OpenApiDiffReportTest.java
new file mode 100644
index 00000000..83e3c54a
--- /dev/null
+++ b/maven/src/test/java/org/openapitools/openapidiff/maven/OpenApiDiffReportTest.java
@@ -0,0 +1,235 @@
+package org.openapitools.openapidiff.maven;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import io.swagger.v3.oas.models.PathItem;
+import java.io.File;
+import java.io.StringWriter;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+import org.apache.maven.doxia.sink.impl.Xhtml5BaseSink;
+import org.apache.maven.project.MavenProject;
+import org.apache.maven.reporting.MavenReport;
+import org.apache.maven.reporting.MavenReportException;
+import org.junit.jupiter.api.Test;
+import org.openapitools.openapidiff.core.OpenApiCompare;
+import org.openapitools.openapidiff.core.model.ChangedOpenApi;
+import org.openapitools.openapidiff.core.model.Endpoint;
+
+class OpenApiDiffReportTest {
+
+ private final File oldSpecFile = new File("src/test/resources/oldspec.yaml");
+ private final File newSpecFile = new File("src/test/resources/newspec.yaml");
+ private final File configFile = new File("src/test/resources/test-config-file.yaml");
+ private final File reportOldSpecFile = new File("src/test/resources/report-oldspec.yaml");
+ private final File reportNewSpecFile = new File("src/test/resources/report-newspec.yaml");
+
+ @Test
+ void Should_DescribeReport_When_ListedInSite() {
+ final OpenApiDiffReport report = new OpenApiDiffReport();
+
+ assertEquals("openapi-diff", report.getOutputName());
+ assertEquals("OpenAPI Diff", report.getName(Locale.ENGLISH));
+ assertEquals(MavenReport.CATEGORY_PROJECT_REPORTS, report.getCategoryName());
+ assertFalse(report.getDescription(Locale.ENGLISH).isEmpty());
+ }
+
+ @Test
+ void Should_RenderNoDifferences_When_SpecHasNoChanges() throws MavenReportException {
+ final String page = render(report(oldSpecFile, oldSpecFile));
+
+ assertTrue(page.contains("No differences. Specifications are equivalent"));
+ assertFalse(page.contains("What's Changed"));
+ }
+
+ @Test
+ void Should_RenderCompatibleResult_When_SpecIsCompatible() throws MavenReportException {
+ final String page = render(report(oldSpecFile, newSpecFile));
+
+ assertTrue(page.contains("API changes are backward compatible"));
+ assertTrue(page.contains("GET /widgets"));
+ assertTrue(page.contains("Changed query-param-1 in query"));
+ assertTrue(page.contains("Changed application/json: schema is backward compatible"));
+ }
+
+ @Test
+ void Should_RenderIncompatibleResult_When_SpecIsIncompatible() throws MavenReportException {
+ final String page = render(report(newSpecFile, oldSpecFile));
+
+ assertTrue(page.contains("API changes broke backward compatibility"));
+ assertTrue(page.contains("Compatibility: Breaks backward compatibility"));
+ assertTrue(page.contains("Changed query-param-1 in query (breaks backward compatibility)"));
+ assertTrue(page.contains("Property prop2 is no longer required"));
+ }
+
+ @Test
+ void Should_ApplyConfiguration_When_IncompatibleChecksDisabled() throws MavenReportException {
+ final OpenApiDiffReport report = report(newSpecFile, oldSpecFile);
+ report.configFiles = Collections.singletonList(configFile);
+ report.configProps =
+ Collections.singletonMap("incompatible.request.params.required.increased", "false");
+
+ final String page = render(report);
+
+ assertTrue(page.contains("API changes are backward compatible"));
+ }
+
+ @Test
+ void Should_RenderEverySection_When_EndpointsAreAddedDeletedDeprecatedAndChanged()
+ throws MavenReportException {
+ final String page = render(report(reportOldSpecFile, reportNewSpecFile));
+
+ assertTrue(page.contains("Pet Store 1.0.0 (report-oldspec.yaml)"));
+ assertTrue(page.contains("Pet Store 2.0.0 (report-newspec.yaml)"));
+ assertTrue(page.contains("API changes broke backward compatibility"));
+
+ assertTrue(page.contains("What's New"));
+ assertTrue(page.contains("/store/orders"));
+ assertTrue(page.contains("What's Deleted"));
+ assertTrue(page.contains("What's Deprecated"));
+ assertTrue(page.contains("/pets/{petId}"));
+
+ assertTrue(page.contains("What's Changed"));
+ assertTrue(page.contains("Operation ID changed from listPets to listAllPets"));
+ assertTrue(page.contains("Added offset in query"));
+ assertTrue(page.contains("Deprecated limit in query"));
+ assertTrue(page.contains("Deleted application/xml"));
+ assertTrue(page.contains("Added 400 Bad Request"));
+ assertTrue(page.contains("Changed application/json: schema breaks compatibility"));
+ assertTrue(page.contains("Changed type of id: integer (int64) -> string"));
+ assertTrue(page.contains("Changed type of [n].id: integer (int64) -> string"));
+ assertTrue(page.contains("Removed property tag (string)"));
+ // the new maxLength only breaks the request body, where clients send the pet
+ assertEquals(1, occurrences(page, "Changed maxLength of name"));
+ }
+
+ @Test
+ void Should_ExplainBreakingAdditionsAndDeletions_When_TheyBreakCompatibility()
+ throws MavenReportException {
+ final String page = render(report(reportOldSpecFile, reportNewSpecFile));
+
+ assertTrue(page.contains("Deleted sort in query (breaks backward compatibility)"));
+ assertTrue(page.contains("Added X-Request-Id in header (breaks backward compatibility)"));
+ assertFalse(page.contains("Added offset in query (breaks"));
+ assertTrue(page.contains("Deleted application/xml (breaks backward compatibility)"));
+ assertTrue(page.contains("Deleted 404 Not Found (breaks backward compatibility)"));
+ assertFalse(page.contains("Added 400 Bad Request (breaks"));
+ }
+
+ @Test
+ void Should_ListOnlyNewlyDeprecatedEndpoints_When_DeprecationIsRemoved()
+ throws MavenReportException {
+ final ChangedOpenApi diff =
+ OpenApiCompare.fromLocations(
+ reportOldSpecFile.getAbsolutePath(), reportNewSpecFile.getAbsolutePath());
+
+ final List deprecated = OpenApiDiffReportRenderer.deprecatedEndpoints(diff);
+
+ assertEquals(1, deprecated.size());
+ assertEquals(PathItem.HttpMethod.GET, deprecated.get(0).getMethod());
+ assertEquals("/pets/{petId}", deprecated.get(0).getPathUrl());
+
+ final String page = render(report(reportOldSpecFile, reportNewSpecFile));
+ assertTrue(page.contains("Operation deprecated"));
+ assertTrue(page.contains("Operation no longer deprecated"));
+ }
+
+ @Test
+ void Should_EscapeSpecificationText_When_Rendering() throws MavenReportException {
+ final String html = renderHtml(report(reportOldSpecFile, reportNewSpecFile));
+
+ assertTrue(html.contains("Place an <b>order</b>"));
+ assertFalse(html.contains("order"));
+ }
+
+ @Test
+ void Should_NotGenerateReport_When_SkipIsTrue() {
+ final OpenApiDiffReport report = report(oldSpecFile, newSpecFile);
+ report.skip = true;
+
+ assertFalse(report.canGenerateReport());
+ }
+
+ @Test
+ void Should_NotGenerateReport_When_SpecsAreNotConfigured() {
+ assertFalse(new OpenApiDiffReport().canGenerateReport());
+ }
+
+ @Test
+ void Should_MavenReportException_When_MissingNewSpec() {
+ final OpenApiDiffReport report = report(oldSpecFile, new File("DOES_NOT_EXIST"));
+
+ assertThrows(MavenReportException.class, () -> render(report));
+ }
+
+ @Test
+ void Should_HideAbsolutePaths_When_DisplayingLocations() {
+ final OpenApiDiffReport report =
+ new OpenApiDiffReport() {
+ {
+ project = new MavenProject();
+ project.setFile(new File("pom.xml").getAbsoluteFile());
+ }
+ };
+
+ assertEquals(
+ "https://example.com/openapi.json",
+ report.displayLocation("https://example.com/openapi.json"));
+ assertEquals(
+ "src/test/resources/oldspec.yaml", report.displayLocation(oldSpecFile.getAbsolutePath()));
+ assertEquals(
+ "openapi.yaml",
+ report.displayLocation(
+ new File(System.getProperty("java.io.tmpdir"), "openapi.yaml").getAbsolutePath()));
+ // file: URIs are files too, so they never show absolute paths
+ assertEquals(
+ "src/test/resources/oldspec.yaml",
+ report.displayLocation(oldSpecFile.getAbsoluteFile().toURI().toString()));
+ assertEquals(
+ "openapi.yaml",
+ report.displayLocation(
+ new File(System.getProperty("java.io.tmpdir"), "openapi.yaml").toURI().toString()));
+ // credentials in the user info or the query must not be published
+ assertEquals(
+ "https://example.com:8443/specs/openapi.json",
+ report.displayLocation(
+ "https://user:secret@example.com:8443/specs/openapi.json?token=abc#latest"));
+ }
+
+ private static int occurrences(String text, String part) {
+ int count = 0;
+ for (int index = text.indexOf(part); index >= 0; index = text.indexOf(part, index + 1)) {
+ count++;
+ }
+ return count;
+ }
+
+ private static OpenApiDiffReport report(File oldSpec, File newSpec) {
+ final OpenApiDiffReport report = new OpenApiDiffReport();
+ report.oldSpec = oldSpec.getAbsolutePath();
+ report.newSpec = newSpec.getAbsolutePath();
+ return report;
+ }
+
+ /** Renders the report as XHTML, as the Maven site does. */
+ private static String renderHtml(OpenApiDiffReport report) throws MavenReportException {
+ final StringWriter writer = new StringWriter();
+ report.generate(new Xhtml5BaseSink(writer), Locale.ENGLISH);
+ return writer.toString();
+ }
+
+ /** Renders the report as the text a reader sees: without tags and with entities unescaped. */
+ private static String render(OpenApiDiffReport report) throws MavenReportException {
+ return renderHtml(report)
+ .replaceAll("<[^>]+>", "")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace(""", "\"")
+ .replace("'", "'")
+ .replace("&", "&");
+ }
+}
diff --git a/maven/src/test/resources/report-newspec.yaml b/maven/src/test/resources/report-newspec.yaml
new file mode 100644
index 00000000..044badca
--- /dev/null
+++ b/maven/src/test/resources/report-newspec.yaml
@@ -0,0 +1,84 @@
+openapi: 3.0.3
+info:
+ title: Pet Store
+ version: 2.0.0
+paths:
+ /pets:
+ get:
+ summary: List pets
+ operationId: listAllPets
+ parameters:
+ - name: limit
+ in: query
+ deprecated: true
+ schema:
+ type: integer
+ - name: offset
+ in: query
+ schema:
+ type: integer
+ - name: X-Request-Id
+ in: header
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: A list of pets
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/Pet'
+ post:
+ summary: Create a pet
+ operationId: createPet
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Pet'
+ responses:
+ '201':
+ description: Created
+ '400':
+ description: Invalid pet
+ /pets/{petId}:
+ get:
+ summary: Info for a specific pet
+ operationId: showPetById
+ deprecated: true
+ parameters:
+ - name: petId
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: The pet
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Pet'
+ /store/orders:
+ post:
+ summary: "Place an order"
+ operationId: placeOrder
+ responses:
+ '201':
+ description: Created
+components:
+ schemas:
+ Pet:
+ type: object
+ required:
+ - id
+ - name
+ properties:
+ id:
+ type: string
+ name:
+ type: string
+ maxLength: 10
diff --git a/maven/src/test/resources/report-oldspec.yaml b/maven/src/test/resources/report-oldspec.yaml
new file mode 100644
index 00000000..18a81d9f
--- /dev/null
+++ b/maven/src/test/resources/report-oldspec.yaml
@@ -0,0 +1,88 @@
+openapi: 3.0.3
+info:
+ title: Pet Store
+ version: 1.0.0
+paths:
+ /pets:
+ get:
+ summary: List pets
+ operationId: listPets
+ parameters:
+ - name: limit
+ in: query
+ schema:
+ type: integer
+ - name: sort
+ in: query
+ schema:
+ type: string
+ responses:
+ '200':
+ description: A list of pets
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/Pet'
+ post:
+ summary: Create a pet
+ operationId: createPet
+ deprecated: true
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Pet'
+ application/xml:
+ schema:
+ $ref: '#/components/schemas/Pet'
+ responses:
+ '201':
+ description: Created
+ /pets/{petId}:
+ get:
+ summary: Info for a specific pet
+ operationId: showPetById
+ parameters:
+ - name: petId
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: The pet
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Pet'
+ '404':
+ description: Not found
+ delete:
+ summary: Delete a pet
+ operationId: deletePet
+ parameters:
+ - name: petId
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ '204':
+ description: Deleted
+components:
+ schemas:
+ Pet:
+ type: object
+ required:
+ - id
+ - name
+ properties:
+ id:
+ type: integer
+ format: int64
+ name:
+ type: string
+ tag:
+ type: string