diff --git a/README.md b/README.md index cdea800bc..d558d321c 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Compare two OpenAPI specifications (3.x) and render the difference to HTML plain * Depth comparison of parameters, responses, endpoint, http method (GET,POST,PUT,DELETE...) * Supports swagger api Authorization * Render difference of property with Expression Language -* HTML, Markdown & JSON render +* HTML, Markdown, Asciidoc & JSON render # Maven @@ -44,6 +44,7 @@ Available on [Docker Hub](https://hub.docker.com/r/openapitools/openapi-diff/) a ```bash # docker run openapitools/openapi-diff:latest usage: openapi-diff + --asciidoc export diff as asciidoc in given file --debug Print debugging information --error Print error information --fail-on-changed Fail if API changed but is backward @@ -101,6 +102,7 @@ openapi-diff can read OpenAPI specs from JSON files or HTTP URLs. ```bash $ openapi-diff --help usage: openapi-diff + --asciidoc export diff as asciidoc in given file --debug Print debugging information --error Print error information -h,--help print this message @@ -204,6 +206,18 @@ try { } ``` +#### Asciidoc + +```java +String render = new AsciidocRender().render(diff); +try { + FileWriter fw = new FileWriter("testDiff.adoc"); + fw.write(render); + fw.close(); +} catch (IOException e) { + e.printStackTrace(); +} +``` #### JSON diff --git a/cli/src/main/java/org/openapitools/openapidiff/cli/Main.java b/cli/src/main/java/org/openapitools/openapidiff/cli/Main.java index f19826a93..4970efcc7 100644 --- a/cli/src/main/java/org/openapitools/openapidiff/cli/Main.java +++ b/cli/src/main/java/org/openapitools/openapidiff/cli/Main.java @@ -17,6 +17,7 @@ import org.apache.commons.lang3.exception.ExceptionUtils; import org.openapitools.openapidiff.core.OpenApiCompare; import org.openapitools.openapidiff.core.model.ChangedOpenApi; +import org.openapitools.openapidiff.core.output.AsciidocRender; import org.openapitools.openapidiff.core.output.ConsoleRender; import org.openapitools.openapidiff.core.output.HtmlRender; import org.openapitools.openapidiff.core.output.JsonRender; @@ -88,6 +89,13 @@ public static void main(String... args) { .argName("file") .desc("export diff as markdown in given file") .build()); + options.addOption( + Option.builder() + .longOpt("asciidoc") + .hasArg() + .argName("file") + .desc("export diff as asciidoc in given file") + .build()); options.addOption( Option.builder() .longOpt("html") @@ -191,6 +199,12 @@ public static void main(String... args) { OutputStreamWriter outputStreamWriter = new OutputStreamWriter(outputStream); mdRender.render(result, outputStreamWriter); } + if (line.hasOption("asciidoc")) { + AsciidocRender adocRender = new AsciidocRender(); + FileOutputStream outputStream = new FileOutputStream(line.getOptionValue("asciidoc")); + OutputStreamWriter outputStreamWriter = new OutputStreamWriter(outputStream); + adocRender.render(result, outputStreamWriter); + } if (line.hasOption("text")) { FileOutputStream outputStream = new FileOutputStream(line.getOptionValue("text")); OutputStreamWriter outputStreamWriter = new OutputStreamWriter(outputStream); diff --git a/core/src/main/java/org/openapitools/openapidiff/core/output/AsciidocRender.java b/core/src/main/java/org/openapitools/openapidiff/core/output/AsciidocRender.java new file mode 100644 index 000000000..997241dc2 --- /dev/null +++ b/core/src/main/java/org/openapitools/openapidiff/core/output/AsciidocRender.java @@ -0,0 +1,311 @@ +package org.openapitools.openapidiff.core.output; + +import static org.openapitools.openapidiff.core.model.Changed.result; + +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.responses.ApiResponse; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Optional; +import org.apache.commons.lang3.StringUtils; +import org.openapitools.openapidiff.core.exception.RendererException; +import org.openapitools.openapidiff.core.model.*; +import org.openapitools.openapidiff.core.utils.RefPointer; +import org.openapitools.openapidiff.core.utils.RefType; + +public class AsciidocRender implements Render { + private static final int LINE_LENGTH = 74; + protected static RefPointer> refPointer = new RefPointer<>(RefType.SCHEMAS); + protected ChangedOpenApi diff; + + @Override + public void render(ChangedOpenApi diff, OutputStreamWriter outputStreamWriter) { + this.diff = diff; + if (diff.isUnchanged()) { + safelyAppend( + outputStreamWriter, + bigTitle( + diff.getNewSpecOpenApi().getInfo().getTitle(), + diff.getNewSpecOpenApi().getInfo().getVersion())); + safelyAppend(outputStreamWriter, System.lineSeparator()); + safelyAppend(outputStreamWriter, System.lineSeparator()); + safelyAppend(outputStreamWriter, "NOTE: No differences. Specifications are equivalents"); + } else { + safelyAppend( + outputStreamWriter, + bigTitle( + diff.getNewSpecOpenApi().getInfo().getTitle(), + diff.getNewSpecOpenApi().getInfo().getVersion())); + safelyAppend(outputStreamWriter, System.lineSeparator()); + safelyAppend(outputStreamWriter, ":reproducible:\n:sectlinks:\n:toc:\n"); + safelyAppend(outputStreamWriter, System.lineSeparator()); + + List newEndpoints = diff.getNewEndpoints(); + listEndpoints(newEndpoints, "What's New", outputStreamWriter); + + List missingEndpoints = diff.getMissingEndpoints(); + listEndpoints(missingEndpoints, "What's Deleted", outputStreamWriter); + + List deprecatedEndpoints = diff.getDeprecatedEndpoints(); + listEndpoints(deprecatedEndpoints, "What's Deprecated", outputStreamWriter); + + List changedOperations = diff.getChangedOperations(); + ol_changed(changedOperations, outputStreamWriter); + + safelyAppend(outputStreamWriter, System.lineSeparator()); + safelyAppend( + outputStreamWriter, + diff.isCompatible() + ? "NOTE: API changes are backward compatible" + : "WARNING: API changes broke backward compatibility"); + safelyAppend(outputStreamWriter, System.lineSeparator()); + } + try { + outputStreamWriter.close(); + } catch (IOException e) { + throw new RendererException(e); + } + } + + private void ol_changed( + List operations, OutputStreamWriter outputStreamWriter) { + if (null == operations || operations.isEmpty()) { + return; + } + safelyAppend(outputStreamWriter, title("What's Changed", 2)); + safelyAppend(outputStreamWriter, System.lineSeparator()); + for (ChangedOperation operation : operations) { + String pathUrl = operation.getPathUrl(); + String method = operation.getHttpMethod().toString(); + String desc = + Optional.ofNullable(operation.getSummary()).map(ChangedMetadata::getRight).orElse(""); + + safelyAppend(outputStreamWriter, itemEndpoint(method, pathUrl, desc)); + if (result(operation.getParameters()).isDifferent()) { + safelyAppend(outputStreamWriter, "* Parameter:\n"); + safelyAppend(outputStreamWriter, ul_param(operation.getParameters())); + safelyAppend(outputStreamWriter, System.lineSeparator()); + } + if (operation.resultRequestBody().isDifferent()) { + safelyAppend(outputStreamWriter, "* Request:\n"); + safelyAppend( + outputStreamWriter, ul_content(operation.getRequestBody().getContent(), true, 2)); + safelyAppend(outputStreamWriter, System.lineSeparator()); + } + if (operation.resultApiResponses().isDifferent()) { + safelyAppend(outputStreamWriter, "* Return Type:\n"); + safelyAppend(outputStreamWriter, ul_response(operation.getApiResponses())); + safelyAppend(outputStreamWriter, System.lineSeparator()); + } + } + } + + private String ul_response(ChangedApiResponse changedApiResponse) { + Map addResponses = changedApiResponse.getIncreased(); + Map delResponses = changedApiResponse.getMissing(); + Map changedResponses = changedApiResponse.getChanged(); + StringBuilder sb = new StringBuilder(); + for (String propName : addResponses.keySet()) { + sb.append(itemResponse("** Add ", propName)); + } + for (String propName : delResponses.keySet()) { + sb.append(itemResponse("** Deleted ", propName)); + } + for (Entry entry : changedResponses.entrySet()) { + sb.append(itemChangedResponse("** Changed ", entry.getKey(), entry.getValue())); + } + return sb.toString(); + } + + private String itemResponse(String title, String code) { + StringBuilder sb = new StringBuilder(); + String status = ""; + if (!code.equals("default") && !code.matches("[1-5]XX")) { + status = HttpStatus.getReasonPhrase(Integer.parseInt(code)); + } + sb.append(title).append(code).append(' ').append(status).append("\n"); + return sb.toString(); + } + + private String itemChangedResponse(String title, String contentType, ChangedResponse response) { + return itemResponse(title, contentType) + + "** Media types:\n" + + ul_content(response.getContent(), false, 3); + } + + private String ul_content(ChangedContent changedContent, boolean isRequest, int indent) { + StringBuilder sb = new StringBuilder(); + if (changedContent == null) { + return sb.toString(); + } + for (String propName : changedContent.getIncreased().keySet()) { + sb.append(itemContent("Added ", propName, indent)); + } + for (String propName : changedContent.getMissing().keySet()) { + sb.append(itemContent("Deleted ", propName, indent)); + } + for (String propName : changedContent.getChanged().keySet()) { + sb.append( + itemContent( + "Changed ", propName, indent, changedContent.getChanged().get(propName), isRequest)); + } + return sb.toString(); + } + + private String itemContent(String title, String contentType, int indent) { + return StringUtils.repeat('*', indent) + " " + title + contentType + "\n"; + } + + private String itemContent( + String title, + String contentType, + int indent, + ChangedMediaType changedMediaType, + boolean isRequest) { + StringBuilder sb = new StringBuilder(); + sb.append(itemContent(title, contentType, indent)) + .append(itemContent("Schema:", "", indent)) + .append(changedMediaType.isCompatible() ? "Backward compatible" : "Broken compatibility") + .append("\n"); + if (!changedMediaType.isCompatible()) { + sb.append(incompatibilities(changedMediaType.getSchema())); + } + return sb.toString(); + } + + private String incompatibilities(final ChangedSchema schema) { + return incompatibilities("", schema); + } + + private String incompatibilities(String propName, final ChangedSchema schema) { + StringBuilder sb = new StringBuilder(); + if (schema.getItems() != null) { + sb.append(items(propName, schema.getItems())); + } + if (schema.isCoreChanged() == DiffResult.INCOMPATIBLE && schema.isChangedType()) { + String type = type(schema.getOldSchema()) + " -> " + type(schema.getNewSchema()); + sb.append(property(propName, "Changed property type", type)); + } + String prefix = propName.isEmpty() ? "" : propName + "."; + sb.append( + properties(prefix, "Missing property", schema.getMissingProperties(), schema.getContext())); + schema + .getChangedProperties() + .forEach((name, property) -> sb.append(incompatibilities(prefix + name, property))); + return sb.toString(); + } + + private String items(String propName, ChangedSchema schema) { + return incompatibilities(propName + "[n]", schema); + } + + private String properties( + String propPrefix, String title, Map> properties, DiffContext context) { + StringBuilder sb = new StringBuilder(); + if (properties != null) { + properties.forEach((key, value) -> sb.append(resolveProperty(propPrefix, value, key, title))); + } + return sb.toString(); + } + + private String resolveProperty(String propPrefix, Schema value, String key, String title) { + try { + return property(propPrefix + key, title, resolve(value)); + } catch (Exception e) { + return property(propPrefix + key, title, type(value)); + } + } + + protected String property(String name, String title, Schema schema) { + return property(name, title, type(schema)); + } + + protected String property(String name, String title, String type) { + return String.format("*** %s: %s (%s)%n\n", title, name, type); + } + + protected Schema resolve(Schema schema) { + return refPointer.resolveRef( + diff.getNewSpecOpenApi().getComponents(), schema, schema.get$ref()); + } + + protected String type(Schema schema) { + String result = "object"; + if (schema == null) { + result = "no schema"; + } else if (schema instanceof ArraySchema) { + result = "array"; + } else if (schema.getType() != null) { + result = schema.getType(); + } + return result; + } + + private String ul_param(ChangedParameters changedParameters) { + List addParameters = changedParameters.getIncreased(); + List delParameters = changedParameters.getMissing(); + List changed = changedParameters.getChanged(); + StringBuilder sb = new StringBuilder(); + for (Parameter param : addParameters) { + sb.append(itemParam("** Add ", param)); + } + for (ChangedParameter param : changed) { + sb.append(li_changedParam(param)); + } + for (Parameter param : delParameters) { + sb.append(itemParam("** Delete ", param)); + } + return sb.toString(); + } + + private String itemParam(String title, Parameter param) { + return title + param.getName() + " in " + param.getIn() + System.lineSeparator(); + } + + private String li_changedParam(ChangedParameter changeParam) { + if (changeParam.isDeprecated()) { + return itemParam("** Deprecated ", changeParam.getNewParameter()); + } else { + return itemParam("** Changed ", changeParam.getNewParameter()); + } + } + + private String listEndpoints( + List endpoints, String title, OutputStreamWriter outputStreamWriter) { + if (null == endpoints || endpoints.isEmpty()) { + return ""; + } + StringBuilder sb = new StringBuilder(); + sb.append(title(title)); + for (Endpoint endpoint : endpoints) { + sb.append( + itemEndpoint( + endpoint.getMethod().toString(), endpoint.getPathUrl(), endpoint.getSummary())); + } + return sb.append(System.lineSeparator()).toString(); + } + + private String itemEndpoint(String method, String path, String desc) { + return String.format("=== %s%s%n", StringUtils.rightPad(method, 6), path); + } + + public String bigTitle(String title, String version) { + char ch = '='; + + return String.format("= %s (v %s)", title.toUpperCase(), version); + } + + public String title(String title) { + return this.title(title, '-'); + } + + public String title(String title, int level) { + String little = StringUtils.repeat("=", level); + return String.format("%s %s", little, title); + } +} diff --git a/core/src/test/java/org/openapitools/openapidiff/core/AsciidocRenderTest.java b/core/src/test/java/org/openapitools/openapidiff/core/AsciidocRenderTest.java new file mode 100644 index 000000000..dae2440eb --- /dev/null +++ b/core/src/test/java/org/openapitools/openapidiff/core/AsciidocRenderTest.java @@ -0,0 +1,96 @@ +package org.openapitools.openapidiff.core; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.ByteArrayOutputStream; +import java.io.OutputStreamWriter; +import org.junit.jupiter.api.Test; +import org.openapitools.openapidiff.core.model.ChangedOpenApi; +import org.openapitools.openapidiff.core.output.AsciidocRender; + +public class AsciidocRenderTest { + @Test + public void renderDoesNotFailWhenPropertyHasBeenRemoved() { + AsciidocRender render = new AsciidocRender(); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + OutputStreamWriter outputStreamWriter = new OutputStreamWriter(outputStream); + ChangedOpenApi diff = + OpenApiCompare.fromLocations("missing_property_1.yaml", "missing_property_2.yaml"); + render.render(diff, outputStreamWriter); + assertThat(outputStream.toString()).isNotBlank(); + } + + @Test + public void renderDoesNotCauseStackOverflowWithRecursiveDefinitions() { + AsciidocRender render = new AsciidocRender(); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + OutputStreamWriter outputStreamWriter = new OutputStreamWriter(outputStream); + ChangedOpenApi diff = OpenApiCompare.fromLocations("recursive_old.yaml", "recursive_new.yaml"); + render.render(diff, outputStreamWriter); + assertThat(outputStream.toString()).isNotBlank(); + } + + @Test + public void renderDoesNotFailWhenHTTPStatusCodeIsRange() { + AsciidocRender render = new AsciidocRender(); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + OutputStreamWriter outputStreamWriter = new OutputStreamWriter(outputStream); + ChangedOpenApi diff = + OpenApiCompare.fromLocations("range_statuscode_1.yaml", "range_statuscode_2.yaml"); + render.render(diff, outputStreamWriter); + assertThat(outputStream.toString()).isNotBlank(); + } + + @Test + public void validateAsciiDocChangeFile() { + AsciidocRender render = new AsciidocRender(); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + OutputStreamWriter outputStreamWriter = new OutputStreamWriter(outputStream); + ChangedOpenApi diff = + OpenApiCompare.fromLocations("missing_property_1.yaml", "missing_property_2.yaml"); + render.render(diff, outputStreamWriter); + assertThat(outputStream.toString()) + .isEqualTo( + "= TITLE (v 1.0.0)\n" + + ":reproducible:\n" + + ":sectlinks:\n" + + ":toc:\n" + + "\n" + + "== What's Changed\n" + + "=== GET /\n" + + "* Return Type:\n" + + "** Changed default \n" + + "** Media types:\n" + + "*** Changed application/json\n" + + "*** Schema:\n" + + "Backward compatible\n" + + "\n" + + "\n" + + "NOTE: API changes are backward compatible\n"); + } + + @Test + public void validateAsciiDocRangeStatus() { + AsciidocRender render = new AsciidocRender(); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + OutputStreamWriter outputStreamWriter = new OutputStreamWriter(outputStream); + ChangedOpenApi diff = + OpenApiCompare.fromLocations("range_statuscode_1.yaml", "range_statuscode_2.yaml"); + render.render(diff, outputStreamWriter); + assertThat(outputStream.toString()) + .isEqualTo( + "= PROJECTS API (v 1.0.0)\n" + + ":reproducible:\n" + + ":sectlinks:\n" + + ":toc:\n" + + "\n" + + "== What's Changed\n" + + "=== GET /pet/\n" + + "* Return Type:\n" + + "** Add 4XX \n" + + "** Deleted 405 Method Not Allowed\n" + + "\n" + + "\n" + + "WARNING: API changes broke backward compatibility\n"); + } +} 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 7e0e82fb7..c9aec95d1 100644 --- a/maven/src/main/java/org/openapitools/openapidiff/maven/OpenApiDiffMojo.java +++ b/maven/src/main/java/org/openapitools/openapidiff/maven/OpenApiDiffMojo.java @@ -14,6 +14,7 @@ import org.apache.maven.plugins.annotations.Parameter; import org.openapitools.openapidiff.core.OpenApiCompare; import org.openapitools.openapidiff.core.model.ChangedOpenApi; +import org.openapitools.openapidiff.core.output.AsciidocRender; import org.openapitools.openapidiff.core.output.ConsoleRender; import org.openapitools.openapidiff.core.output.JsonRender; import org.openapitools.openapidiff.core.output.MarkdownRender; @@ -46,6 +47,9 @@ public class OpenApiDiffMojo extends AbstractMojo { @Parameter(property = "markdownOutputFileName") String markdownOutputFileName; + @Parameter(property = "asciidocOutputFileName") + String asciidocOutputFileName; + @Override public void execute() throws MojoExecutionException, MojoFailureException { if (Boolean.TRUE.equals(skip)) { @@ -67,6 +71,7 @@ public void execute() throws MojoExecutionException, MojoFailureException { writeDiffAsTextToFile(diff); writeDiffAsJsonToFile(diff); writeDiffAsMarkdownToFile(diff); + writeDiffAsAsciidocToFile(diff); if (failOnIncompatible && diff.isIncompatible()) { throw new BackwardIncompatibilityException("The API changes broke backward compatibility"); @@ -91,4 +96,8 @@ private void writeDiffAsJsonToFile(final ChangedOpenApi diff) { private void writeDiffAsMarkdownToFile(final ChangedOpenApi diff) { writeToFile(new MarkdownRender(), diff, markdownOutputFileName); } + + private void writeDiffAsAsciidocToFile(final ChangedOpenApi diff) { + writeToFile(new AsciidocRender(), diff, asciidocOutputFileName); + } } diff --git a/maven/src/test/java/org/openapitools/openapidiff/maven/OpenApiDiffMojoTest.java b/maven/src/test/java/org/openapitools/openapidiff/maven/OpenApiDiffMojoTest.java index 10bd186e5..01f166fe4 100644 --- a/maven/src/test/java/org/openapitools/openapidiff/maven/OpenApiDiffMojoTest.java +++ b/maven/src/test/java/org/openapitools/openapidiff/maven/OpenApiDiffMojoTest.java @@ -25,6 +25,7 @@ class OpenApiDiffMojoTest { private final File consoleOutputfile = new File("target/diff.txt"); private final File markdownOutputfile = new File("target/diff.md"); private final File jsonOutputfile = new File("target/diff.json"); + private final File asciidocOutputfile = new File("target/diff.adoc"); @BeforeEach void setup() { @@ -158,6 +159,20 @@ void Should_outputToJsonFile_When_SpecIsDifferent() { assertTrue(Files.exists(jsonOutputfile.toPath())); } + @Test + void Should_outputToAsccidocFile_When_SpecIsDifferent() { + final OpenApiDiffMojo mojo = new OpenApiDiffMojo(); + mojo.oldSpec = oldSpecFile.getAbsolutePath(); + mojo.newSpec = newSpecFile.getAbsolutePath(); + + mojo.asciidocOutputFileName = asciidocOutputfile.getAbsolutePath(); + mojo.failOnChanged = true; + + assertThrows(ApiChangedException.class, mojo::execute); + + assertTrue(Files.exists(asciidocOutputfile.toPath())); + } + private void cleanupGeneratedFiles() { try { Files.deleteIfExists(Paths.get(consoleOutputfile.getPath()));