diff --git a/README.md b/README.md index 3bd0608e2..5ea853a5f 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,8 @@ usage: openapi-diff --header use given header for authorisation --html export diff as html in given file --info Print additional information + --ignore comma-separated list of attributes to + ignore -l,--log use given level for log (TRACE, DEBUG, INFO, WARN, ERROR, OFF). Default: ERROR --markdown export diff as markdown in given file @@ -104,6 +106,8 @@ usage: openapi-diff --header use given header for authorisation --html export diff as html in given file --info Print additional information + --ignore comma-separated list of attributes to + ignore -l,--log use given level for log (TRACE, DEBUG, INFO, WARN, ERROR, OFF). Default: ERROR --markdown export diff as markdown in given file @@ -350,6 +354,52 @@ Then, including your library with the `openapi-diff` module will cause it to be Changed response : [200] //successful operation ``` +# Exclusions + +To ignore certain paths or http operations, use the `--ignore` argument along with a comma seperated list of (`x-`) attributes. For example, consider `--ignore x-internal,x-ignore` with the example below: + +```text +paths: + /pet/{petId}: + get: + tags: + - pet + summary: gets a pet by id + description: '' + operationId: updatePetWithForm + parameters: + - name: petId + in: path + description: ID of pet that needs to be updated + required: true + schema: + type: integer + responses: + '405': + description: Invalid input + x-internal: true + /pet/cat/{catId}: + get: + tags: + - cat + summary: gets a cat by id + description: '' + operationId: updateCatWithForm + parameters: + - name: catId + in: path + description: ID of cat that needs to be updated + required: true + schema: + type: string + responses: + '405': + description: Invalid input + x-ignore: true +``` + +Any breaking changes in GET `/pet/{petId}` or all operations for `/pet/cat/{catId}` will be ignored. + # License openapi-diff is released under the Apache License 2.0. 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 04472fe76..d6a3ba184 100644 --- a/cli/src/main/java/org/openapitools/openapidiff/cli/Main.java +++ b/cli/src/main/java/org/openapitools/openapidiff/cli/Main.java @@ -2,6 +2,8 @@ import java.io.File; import java.io.IOException; +import java.util.Arrays; +import java.util.List; import java.nio.charset.StandardCharsets; import org.apache.commons.cli.CommandLine; import org.apache.commons.cli.CommandLineParser; @@ -15,6 +17,7 @@ import org.apache.log4j.Level; import org.apache.log4j.LogManager; import org.openapitools.openapidiff.core.OpenApiCompare; +import org.openapitools.openapidiff.core.compare.IgnoreDiff; import org.openapitools.openapidiff.core.model.ChangedOpenApi; import org.openapitools.openapidiff.core.output.ConsoleRender; import org.openapitools.openapidiff.core.output.HtmlRender; @@ -100,6 +103,13 @@ public static void main(String... args) { .argName("file") .desc("export diff as text in given file") .build()); + options.addOption( + Option.builder() + .longOpt("ignore") + .hasArg() + .argName("attributesList") + .desc("comma-separated list of attributes to ignore") + .build()); // create the parser CommandLineParser parser = new DefaultParser(); @@ -154,6 +164,14 @@ public static void main(String... args) { String oldPath = line.getArgList().get(0); String newPath = line.getArgList().get(1); ChangedOpenApi result = OpenApiCompare.fromLocations(oldPath, newPath); + List ignoredAttributesList; + IgnoreDiff ignoreDiff; + if (line.hasOption("ignore")) { + ignoredAttributesList = Arrays.asList(line.getOptionValue("ignore").split(",")); + ignoreDiff = new IgnoreDiff(ignoredAttributesList, oldPath); + ignoreDiff.setSkippedPaths(); + result = ignoreDiff.removeSkippedPaths(result); + } ConsoleRender consoleRender = new ConsoleRender(); if (!logLevel.equals("OFF")) { System.out.println(consoleRender.render(result)); diff --git a/core/src/main/java/org/openapitools/openapidiff/core/compare/IgnoreDiff.java b/core/src/main/java/org/openapitools/openapidiff/core/compare/IgnoreDiff.java new file mode 100644 index 000000000..2d64fe40c --- /dev/null +++ b/core/src/main/java/org/openapitools/openapidiff/core/compare/IgnoreDiff.java @@ -0,0 +1,164 @@ +package org.openapitools.openapidiff.core.compare; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; +import io.swagger.v3.parser.core.models.AuthorizationValue; +import io.swagger.v3.parser.exception.ReadContentException; +import io.swagger.v3.parser.util.ClasspathHelper; +import io.swagger.v3.parser.util.RemoteUrl; +import java.net.URI; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import javax.net.ssl.SSLHandshakeException; +import org.apache.commons.io.FileUtils; +import org.openapitools.openapidiff.core.model.ChangedOpenApi; +import org.openapitools.openapidiff.core.model.ChangedOperation; +import org.openapitools.openapidiff.core.model.Endpoint; + +// [Parts of this file were taken from https://github.com/swagger-api/swagger-parser] + +public class IgnoreDiff { + private List ignoredAttributesList; + private String specPath; + private String specString; + private static ObjectMapper JSON_MAPPER, YAML_MAPPER; + private static String encoding = StandardCharsets.UTF_8.displayName(); + static ObjectMapper mapper; + private JsonNode rootNode; + private JsonNode pathsNode; + private Map> skippedPaths = new HashMap>(); + + static { + JSON_MAPPER = new JsonMapper(); + YAML_MAPPER = new YAMLMapper(); + } + + public IgnoreDiff(List ignoredAttributesList, String specPath) { + this.ignoredAttributesList = ignoredAttributesList; + this.specPath = specPath; + this.specString = readContentFromLocation(specPath, null); + this.mapper = getRightMapper(specString); + try { + this.rootNode = mapper.readTree(specString); + this.pathsNode = getNode("paths", rootNode); + } catch (JsonProcessingException e) { + e.printStackTrace(); + } + } + + private String readContentFromLocation(String location, List auth) { + final String adjustedLocation = location.replaceAll("\\\\", "/"); + try { + if (adjustedLocation.toLowerCase().startsWith("http")) { + return RemoteUrl.urlToString(adjustedLocation, auth); + } else { + final String fileScheme = "file:"; + final Path path = adjustedLocation.toLowerCase().startsWith(fileScheme) ? + Paths.get(URI.create(adjustedLocation)) : Paths.get(adjustedLocation); + if (Files.exists(path)) { + return FileUtils.readFileToString(path.toFile(), encoding); + } else { + return ClasspathHelper.loadFileFromClasspath(adjustedLocation); + } + } + } catch (SSLHandshakeException e) { + final String message = String.format( + "Unable to read location `%s` due to a SSL configuration error. It is possible that the server SSL certificate is invalid, self-signed, or has an untrusted Certificate Authority.", + adjustedLocation); + throw new ReadContentException(message, e); + } catch (Exception e) { + throw new ReadContentException(String.format("Unable to read location `%s`", adjustedLocation), e); + } + } + + private ObjectMapper getRightMapper(String data) { + if (data.trim().startsWith("{")) { + return JSON_MAPPER; + } + return YAML_MAPPER; + } + + private JsonNode getNode(String key, JsonNode parentNode) { + JsonNode node = parentNode.get(key); + return node; + } + + public void setSkippedPaths() { + List httpVerbs = Stream.of("get", "put", "post", "delete", "patch").collect(Collectors.toList()); + + Iterator> paths = this.pathsNode.fields(); + while (paths.hasNext()) { + Map.Entry pathObj = (Map.Entry) paths.next(); + String path = pathObj.getKey(); + JsonNode node = pathObj.getValue(); + Set skippedVerbs = new HashSet<>(); + for (String attribute : ignoredAttributesList) { + JsonNode childNode = getNode(attribute, node); + if (childNode != null) { + skippedVerbs.add("all"); + break; + } + } + if (skippedVerbs.size() > 0) { + this.skippedPaths.put(path, skippedVerbs); + continue; + } + for (String httpVerb : httpVerbs) { + JsonNode verbNode = getNode(httpVerb, node); + if (verbNode != null) { + for (String attribute : ignoredAttributesList) { + JsonNode childNode = getNode(attribute, verbNode); + if (childNode != null) { + skippedVerbs.add(httpVerb); + break; + } + } + } + } + if (skippedVerbs.size() > 0) { + this.skippedPaths.put(path, skippedVerbs); + continue; + } + } + + } + + public ChangedOpenApi removeSkippedPaths(ChangedOpenApi diff) { + List changedOperations = diff.getChangedOperations(); + List filteredOperations = changedOperations.stream() + .filter( + operation -> { + String pathUrl = operation.getPathUrl(); + String method = operation.getHttpMethod().toString(); + Set skippedPathObj = skippedPaths.get(pathUrl); + return (skippedPathObj == null || !( skippedPathObj.contains("all") || skippedPathObj.contains(method.toLowerCase()) )); + } + ).collect(Collectors.toList()); + diff.setChangedOperations(filteredOperations); + + List missingEndpoints = diff.getMissingEndpoints(); + List filteredEndpoints = missingEndpoints.stream() + .filter( + endpoint -> { + String pathUrl = endpoint.getPathUrl(); + String method = endpoint.getMethod().toString(); + Set skippedPathObj = skippedPaths.get(pathUrl); + return (skippedPathObj == null || !( skippedPathObj.contains("all") || skippedPathObj.contains(method.toLowerCase()) )); + } + ).collect(Collectors.toList()); + + diff.setMissingEndpoints(filteredEndpoints); + + return diff; + } + +} \ No newline at end of file diff --git a/core/src/test/java/org/openapitools/openapidiff/core/IgnoreDiffTest.java b/core/src/test/java/org/openapitools/openapidiff/core/IgnoreDiffTest.java new file mode 100644 index 000000000..7e5b14ac3 --- /dev/null +++ b/core/src/test/java/org/openapitools/openapidiff/core/IgnoreDiffTest.java @@ -0,0 +1,58 @@ +package org.openapitools.openapidiff.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.slf4j.LoggerFactory.getLogger; + +import java.util.ArrayList; +import org.junit.jupiter.api.Test; +import org.openapitools.openapidiff.core.compare.IgnoreDiff; +import org.openapitools.openapidiff.core.model.ChangedOpenApi; +import org.slf4j.Logger; + +public class IgnoreDiffTest { + private final String OPENAPI_IGNOREDIFF1 = "ignore_diff_1.yaml"; + private final String OPENAPI_IGNOREDIFF2 = "ignore_diff_2.yaml"; + private final String OPENAPI_IGNOREDIFF3 = "ignore_diff_3.yaml"; + + public static final Logger LOG = getLogger(TestUtils.class); + + @Test + public void testEqualAfterIgnore() { + ChangedOpenApi changedOpenApi = OpenApiCompare.fromLocations(OPENAPI_IGNOREDIFF1, OPENAPI_IGNOREDIFF2); + + assertThat(changedOpenApi.isIncompatible()).isTrue(); + + ArrayList ignoredAttributesList = new ArrayList(); + ignoredAttributesList.add("x-internal"); + IgnoreDiff ignoreDiff = new IgnoreDiff(ignoredAttributesList, OPENAPI_IGNOREDIFF1); + ignoreDiff.setSkippedPaths(); + changedOpenApi = ignoreDiff.removeSkippedPaths(changedOpenApi); + + LOG.info("Result: {}", changedOpenApi.isChanged().getValue()); + assertThat(changedOpenApi.getNewEndpoints()).isEmpty(); + assertThat(changedOpenApi.getMissingEndpoints()).isEmpty(); + assertThat(changedOpenApi.getChangedOperations()).isEmpty(); + assertThat(changedOpenApi.isCompatible()).isTrue(); + } + + @Test + public void testNotEqualWithoutIgnore() { + ChangedOpenApi changedOpenApi = OpenApiCompare.fromLocations(OPENAPI_IGNOREDIFF2, OPENAPI_IGNOREDIFF3); + + assertThat(changedOpenApi.isIncompatible()).isTrue(); + + ArrayList ignoredAttributesList = new ArrayList(); + ignoredAttributesList.add("x-internal"); + IgnoreDiff ignoreDiff = new IgnoreDiff(ignoredAttributesList, OPENAPI_IGNOREDIFF2); + ignoreDiff.setSkippedPaths(); + changedOpenApi = ignoreDiff.removeSkippedPaths(changedOpenApi); + + LOG.info("Result: {}", changedOpenApi.isChanged().getValue()); + assertThat(changedOpenApi.getNewEndpoints()).isEmpty(); + assertThat(changedOpenApi.getMissingEndpoints()).isEmpty(); + assertThat(changedOpenApi.getChangedOperations()).isNotEmpty(); + assertThat(changedOpenApi.isIncompatible()).isTrue(); + } + +} diff --git a/core/src/test/java/org/openapitools/openapidiff/core/PathDiffTest.java b/core/src/test/java/org/openapitools/openapidiff/core/PathDiffTest.java index 71fbc938b..04d779ec0 100644 --- a/core/src/test/java/org/openapitools/openapidiff/core/PathDiffTest.java +++ b/core/src/test/java/org/openapitools/openapidiff/core/PathDiffTest.java @@ -21,4 +21,5 @@ public void testMultiplePathWithSameSignature() { assertThrows( IllegalArgumentException.class, () -> assertOpenApiAreEquals(OPENAPI_PATH3, OPENAPI_PATH3)); } + } diff --git a/core/src/test/resources/ignore_diff_1.yaml b/core/src/test/resources/ignore_diff_1.yaml new file mode 100644 index 000000000..bd36813ab --- /dev/null +++ b/core/src/test/resources/ignore_diff_1.yaml @@ -0,0 +1,87 @@ +openapi: 3.0.0 +servers: + - url: 'http://petstore.swagger.io/v2' +info: + description: >- + This is a sample server Petstore server. You can find out more about + Swagger at [http://swagger.io](http://swagger.io) or on [irc.freenode.net, + #swagger](http://swagger.io/irc/). For this sample, you can use the api key + `special-key` to test the authorization filters. + version: 1.0.0 + title: Swagger Petstore + termsOfService: 'http://swagger.io/terms/' + contact: + email: apiteam@swagger.io + license: + name: Apache 2.0 + url: 'http://www.apache.org/licenses/LICENSE-2.0.html' +paths: + /pet/{petId}: + get: + tags: + - pet + summary: gets a pet by id + description: '' + operationId: updatePetWithForm + parameters: + - name: petId + in: path + description: ID of pet that needs to be updated + required: true + schema: + type: string + responses: + '405': + description: Invalid input + x-internal: true + delete: + tags: + - pet + summary: deletes a pet by id + description: '' + operationId: deletePet + parameters: + - name: petId + in: path + description: ID of pet that needs will be deleted + required: true + schema: + type: string + responses: + '405': + description: Invalid input + x-internal: true + /pet/cat/{catId}: + get: + tags: + - cat + summary: gets a cat by id + description: '' + operationId: updateCatWithForm + parameters: + - name: catId + in: path + description: ID of cat that needs to be updated + required: true + schema: + type: string + responses: + '405': + description: Invalid input + delete: + tags: + - cat + summary: deletes a cat by id + description: '' + operationId: deleteCat + parameters: + - name: catId + in: path + description: ID of cat that needs will be deleted + required: true + schema: + type: string + responses: + '405': + description: Invalid input + x-internal: true \ No newline at end of file diff --git a/core/src/test/resources/ignore_diff_2.yaml b/core/src/test/resources/ignore_diff_2.yaml new file mode 100644 index 000000000..6ee93e6ae --- /dev/null +++ b/core/src/test/resources/ignore_diff_2.yaml @@ -0,0 +1,70 @@ +openapi: 3.0.0 +servers: + - url: 'http://petstore.swagger.io/v2' +info: + description: >- + This is a sample server Petstore server. You can find out more about + Swagger at [http://swagger.io](http://swagger.io) or on [irc.freenode.net, + #swagger](http://swagger.io/irc/). For this sample, you can use the api key + `special-key` to test the authorization filters. + version: 1.0.0 + title: Swagger Petstore + termsOfService: 'http://swagger.io/terms/' + contact: + email: apiteam@swagger.io + license: + name: Apache 2.0 + url: 'http://www.apache.org/licenses/LICENSE-2.0.html' +paths: + /pet/{petId}: + get: + tags: + - pet + summary: gets a pet by id + description: '' + operationId: updatePetWithForm + parameters: + - name: petId + in: path + description: ID of pet that needs to be updated + required: true + schema: + type: integer + responses: + '405': + description: Invalid input + x-internal: true + /pet/cat/{catId}: + get: + tags: + - cat + summary: gets a cat by id + description: '' + operationId: updateCatWithForm + parameters: + - name: catId + in: path + description: ID of cat that needs to be updated + required: true + schema: + type: string + responses: + '405': + description: Invalid input + delete: + tags: + - cat + summary: deletes a cat by id + description: '' + operationId: deleteCat + parameters: + - name: catId + in: path + description: ID of cat that needs will be deleted + required: true + schema: + type: integer + responses: + '405': + description: Invalid input + x-internal: true \ No newline at end of file diff --git a/core/src/test/resources/ignore_diff_3.yaml b/core/src/test/resources/ignore_diff_3.yaml new file mode 100644 index 000000000..933558dcf --- /dev/null +++ b/core/src/test/resources/ignore_diff_3.yaml @@ -0,0 +1,70 @@ +openapi: 3.0.0 +servers: + - url: 'http://petstore.swagger.io/v2' +info: + description: >- + This is a sample server Petstore server. You can find out more about + Swagger at [http://swagger.io](http://swagger.io) or on [irc.freenode.net, + #swagger](http://swagger.io/irc/). For this sample, you can use the api key + `special-key` to test the authorization filters. + version: 1.0.0 + title: Swagger Petstore + termsOfService: 'http://swagger.io/terms/' + contact: + email: apiteam@swagger.io + license: + name: Apache 2.0 + url: 'http://www.apache.org/licenses/LICENSE-2.0.html' +paths: + /pet/{petId}: + get: + tags: + - pet + summary: gets a pet by id + description: '' + operationId: updatePetWithForm + parameters: + - name: petId + in: path + description: ID of pet that needs to be updated + required: true + schema: + type: integer + responses: + '405': + description: Invalid input + x-internal: true + /pet/cat/{catId}: + get: + tags: + - cat + summary: gets a cat by id + description: '' + operationId: updateCatWithForm + parameters: + - name: catId + in: path + description: ID of cat that needs to be updated + required: true + schema: + type: integer + responses: + '405': + description: Invalid input + delete: + tags: + - cat + summary: deletes a cat by id + description: '' + operationId: deleteCat + parameters: + - name: catId + in: path + description: ID of cat that needs will be deleted + required: true + schema: + type: integer + responses: + '405': + description: Invalid input + x-internal: true \ No newline at end of file