diff --git a/README.md b/README.md index 8538d1e7..aef749c3 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,50 @@ Add openapi-diff to your POM to show diffs when you test your Maven project. You ``` +### Maven Site Report + +The `report` goal adds an *OpenAPI Diff* page to the *Project Reports* of your [Maven site](https://maven.apache.org/plugins/maven-site-plugin/). The page shows the overall result and the new, deleted, deprecated and changed endpoints, and explains which changes break backward compatibility. It accepts the same `oldSpec`, `newSpec`, `configFiles`, `configProps` and `skip` parameters as the `diff` goal. The report only documents changes and never fails the build; use the `diff` goal for that. + +```xml + + + + + + org.apache.maven.plugins + maven-site-plugin + 3.22.0 + + + + + + + + + org.openapitools.openapidiff + openapi-diff-maven + ${openapi-diff-version} + + https://petstore3.swagger.io/api/v3/openapi.json + ${project.basedir}/target/openapi.yaml + + + + + report + + + + + + +``` + +If the new specification is generated during the build, run `mvn verify site` so it exists when the site is rendered. + +The page can also be generated on its own with `mvn -DoldSpec= -DnewSpec= org.openapitools.openapidiff:openapi-diff-maven:report`, which writes `target/reports/openapi-diff.html`. Maven only applies the `` configuration when it renders the site, so a direct run needs the specifications as properties, or in the plugin's `` under ``. + ## Direct Invocation ```java diff --git a/maven-example/pom.xml b/maven-example/pom.xml index de3ac08f..7f1f787f 100644 --- a/maven-example/pom.xml +++ b/maven-example/pom.xml @@ -19,6 +19,16 @@ + + + + + org.apache.maven.plugins + maven-site-plugin + 3.22.0 + + + org.openapitools.openapidiff @@ -49,4 +59,27 @@ + + + true + + + org.openapitools.openapidiff + openapi-diff-maven + ${project.version} + + ${project.basedir}/../maven/src/test/resources/oldspec.yaml + ${project.basedir}/../maven/src/test/resources/newspec.yaml + + + + + report + + + + + + + diff --git a/maven/pom.xml b/maven/pom.xml index f5ec9bc2..ed2c9058 100644 --- a/maven/pom.xml +++ b/maven/pom.xml @@ -33,6 +33,17 @@ 3.4 provided + + + org.apache.maven.reporting + maven-reporting-api + 4.0.0 + + + org.apache.maven.reporting + maven-reporting-impl + 4.0.0 + org.openapitools.openapidiff diff --git a/maven/src/main/java/org/openapitools/openapidiff/maven/OpenApiDiffMojo.java b/maven/src/main/java/org/openapitools/openapidiff/maven/OpenApiDiffMojo.java index 4fd7d9e1..36e4bd01 100644 --- a/maven/src/main/java/org/openapitools/openapidiff/maven/OpenApiDiffMojo.java +++ b/maven/src/main/java/org/openapitools/openapidiff/maven/OpenApiDiffMojo.java @@ -68,14 +68,7 @@ public void execute() throws MojoExecutionException, MojoFailureException { } try { - OpenApiDiffOptions.Builder optionBuilder = OpenApiDiffOptions.builder(); - if (configFiles != null) { - configFiles.forEach(optionBuilder::configYaml); - } - if (configProps != null) { - configProps.forEach(optionBuilder::configProperty); - } - OpenApiDiffOptions options = optionBuilder.build(); + OpenApiDiffOptions options = OpenApiDiffOptionsFactory.create(configFiles, configProps); final ChangedOpenApi diff = OpenApiCompare.fromLocations(oldSpec, newSpec, null, options); diff --git a/maven/src/main/java/org/openapitools/openapidiff/maven/OpenApiDiffOptionsFactory.java b/maven/src/main/java/org/openapitools/openapidiff/maven/OpenApiDiffOptionsFactory.java new file mode 100644 index 00000000..6199b120 --- /dev/null +++ b/maven/src/main/java/org/openapitools/openapidiff/maven/OpenApiDiffOptionsFactory.java @@ -0,0 +1,31 @@ +package org.openapitools.openapidiff.maven; + +import java.io.File; +import java.util.List; +import java.util.Map; +import org.openapitools.openapidiff.core.compare.OpenApiDiffOptions; + +/** Builds the comparison options shared by the {@code diff} and {@code report} goals. */ +final class OpenApiDiffOptionsFactory { + + private OpenApiDiffOptionsFactory() {} + + /** + * Creates the options from the plugin configuration. Config files are applied first and config + * properties are applied last, so properties override values from the files. + * + * @param configFiles YAML config files, may be {@code null} + * @param configProps config properties, may be {@code null} + * @return the comparison options + */ + static OpenApiDiffOptions create(List configFiles, Map 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