diff --git a/boat-engine/pom.xml b/boat-engine/pom.xml index 8855f94cc..cf3562360 100644 --- a/boat-engine/pom.xml +++ b/boat-engine/pom.xml @@ -5,7 +5,7 @@ com.backbase.oss backbase-openapi-tools - 0.18.5-SNAPSHOT + 0.19.0-SNAPSHOT boat-engine jar @@ -39,6 +39,12 @@ jakarta.validation-api + + org.projectlombok + lombok + provided + + ch.qos.logback logback-classic @@ -86,6 +92,19 @@ + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + ${lombok.version} + + + + diff --git a/boat-engine/src/main/java/com/backbase/oss/boat/serializer/SerializerUtils.java b/boat-engine/src/main/java/com/backbase/oss/boat/serializer/SerializerUtils.java index 0fca09ee2..cf7356aa5 100644 --- a/boat-engine/src/main/java/com/backbase/oss/boat/serializer/SerializerUtils.java +++ b/boat-engine/src/main/java/com/backbase/oss/boat/serializer/SerializerUtils.java @@ -1,7 +1,12 @@ package com.backbase.oss.boat.serializer; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.swagger.v3.core.util.Json; +import io.swagger.v3.core.util.Json31; import io.swagger.v3.core.util.Yaml; +import io.swagger.v3.core.util.Yaml31; import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.SpecVersion; import lombok.experimental.UtilityClass; import lombok.extern.slf4j.Slf4j; @@ -9,11 +14,74 @@ @UtilityClass public class SerializerUtils { + private static final String OPENAPI_31_PREFIX = "3.1"; + + /** + * Whether the document must be treated as OpenAPI 3.1.x rather than 3.0.x. + * + *

The parser sets {@link OpenAPI#getSpecVersion()} while reading, so that is the primary signal. The + * {@code openapi} field is used as a fallback because {@code new OpenAPI()} defaults the spec version to + * {@link SpecVersion#V30}: a document that lost its spec version in an earlier round trip is still + * recognised from its header. + * + * @param openAPI the document to inspect, may be null + * @return true when the document is OpenAPI 3.1.x, false for 3.0.x and for null + */ + public static boolean isOpenApi31(OpenAPI openAPI) { + if (openAPI == null) { + return false; + } + if (openAPI.getSpecVersion() == SpecVersion.V31) { + return true; + } + String version = openAPI.getOpenapi(); + return version != null && version.startsWith(OPENAPI_31_PREFIX); + } + public static String toYamlString(OpenAPI openAPI) { if (openAPI == null) { return null; } + if (isOpenApi31(openAPI)) { + log.debug("Serializing OpenAPI {} as YAML using the 3.1 mapper", openAPI.getOpenapi()); + return Yaml31.pretty(openAPI); + } return Yaml.pretty(openAPI); } + public static String toJsonString(OpenAPI openAPI) { + if (openAPI == null) { + return null; + } + if (isOpenApi31(openAPI)) { + log.debug("Serializing OpenAPI {} as JSON using the 3.1 mapper", openAPI.getOpenapi()); + return Json31.pretty(openAPI); + } + return Json.pretty(openAPI); + } + + /** + * The YAML mapper matching the document's spec version. + * + *

Note that swagger-core returns shared singletons here: callers must read from the mapper without + * reconfiguring it. + * + * @param openAPI the document the mapper will be used on, may be null + * @return the 3.1 mapper for 3.1 documents, the 3.0 mapper otherwise + */ + public static ObjectMapper yamlMapper(OpenAPI openAPI) { + return isOpenApi31(openAPI) ? Yaml31.mapper() : Yaml.mapper(); + } + + /** + * The JSON mapper matching the document's spec version. Shares the singleton caveat of + * {@link #yamlMapper(OpenAPI)}. + * + * @param openAPI the document the mapper will be used on, may be null + * @return the 3.1 mapper for 3.1 documents, the 3.0 mapper otherwise + */ + public static ObjectMapper jsonMapper(OpenAPI openAPI) { + return isOpenApi31(openAPI) ? Json31.mapper() : Json.mapper(); + } + } diff --git a/boat-engine/src/main/java/com/backbase/oss/boat/transformers/DeduplicateSchemasTransformer.java b/boat-engine/src/main/java/com/backbase/oss/boat/transformers/DeduplicateSchemasTransformer.java index bd0891979..b21976f33 100644 --- a/boat-engine/src/main/java/com/backbase/oss/boat/transformers/DeduplicateSchemasTransformer.java +++ b/boat-engine/src/main/java/com/backbase/oss/boat/transformers/DeduplicateSchemasTransformer.java @@ -1,9 +1,10 @@ package com.backbase.oss.boat.transformers; +import com.backbase.oss.boat.serializer.SerializerUtils; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.TextNode; -import io.swagger.v3.core.util.Json; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.Paths; @@ -60,7 +61,8 @@ public OpenAPI transform(OpenAPI openAPI, Map options) { } Map schemas = openAPI.getComponents().getSchemas(); - Map renames = findDuplicateRenames(schemas); + ObjectMapper mapper = SerializerUtils.jsonMapper(openAPI); + Map renames = findDuplicateRenames(schemas, mapper); if (renames.isEmpty()) { log.debug("No duplicate schemas found."); @@ -70,7 +72,7 @@ public OpenAPI transform(OpenAPI openAPI, Map options) { renames.forEach((duplicate, canonical) -> log.info("Merging duplicate schema '{}' into '{}'.", duplicate, canonical)); - rewriteReferences(openAPI, renames); + rewriteReferences(openAPI, renames, mapper); // rewriteReferences() replaces components with a freshly deserialized instance, so the removal // must happen against the new schemas map, not the one captured before the rewrite. renames.keySet().forEach(openAPI.getComponents().getSchemas()::remove); @@ -82,11 +84,11 @@ public OpenAPI transform(OpenAPI openAPI, Map options) { * Groups schemas by structural equality (their serialized JSON representation) and, for every group with * more than one member, maps every non-canonical member's name onto the canonical one. */ - private Map findDuplicateRenames(Map schemas) { + private Map findDuplicateRenames(Map schemas, ObjectMapper mapper) { Map> byContent = new LinkedHashMap<>(); schemas.forEach((name, schema) -> { - JsonNode node = Json.mapper().valueToTree(schema); + JsonNode node = mapper.valueToTree(schema); byContent.computeIfAbsent(node, key -> new ArrayList<>()).add(name); }); @@ -105,14 +107,14 @@ private Map findDuplicateRenames(Map schemas) { return renames; } - private void rewriteReferences(OpenAPI openAPI, Map renames) { - JsonNode pathsNode = Json.mapper().valueToTree(openAPI.getPaths()); + private void rewriteReferences(OpenAPI openAPI, Map renames, ObjectMapper mapper) { + JsonNode pathsNode = mapper.valueToTree(openAPI.getPaths()); rewriteRefs(pathsNode, renames); - openAPI.setPaths(Json.mapper().convertValue(pathsNode, Paths.class)); + openAPI.setPaths(mapper.convertValue(pathsNode, Paths.class)); - JsonNode componentsNode = Json.mapper().valueToTree(openAPI.getComponents()); + JsonNode componentsNode = mapper.valueToTree(openAPI.getComponents()); rewriteRefs(componentsNode, renames); - openAPI.setComponents(Json.mapper().convertValue(componentsNode, Components.class)); + openAPI.setComponents(mapper.convertValue(componentsNode, Components.class)); } private void rewriteRefs(JsonNode node, Map renames) { diff --git a/boat-engine/src/main/java/com/backbase/oss/boat/transformers/ExtensionFilter.java b/boat-engine/src/main/java/com/backbase/oss/boat/transformers/ExtensionFilter.java index af3993920..47a6fa60f 100644 --- a/boat-engine/src/main/java/com/backbase/oss/boat/transformers/ExtensionFilter.java +++ b/boat-engine/src/main/java/com/backbase/oss/boat/transformers/ExtensionFilter.java @@ -1,5 +1,6 @@ package com.backbase.oss.boat.transformers; +import com.backbase.oss.boat.serializer.SerializerUtils; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ContainerNode; @@ -11,7 +12,6 @@ import java.util.Map; import java.util.Spliterator; -import io.swagger.v3.core.util.Yaml; import io.swagger.v3.oas.models.OpenAPI; import lombok.Getter; import lombok.NonNull; @@ -49,15 +49,18 @@ public class ExtensionFilter implements Transformer { } @SneakyThrows - private OpenAPI transform(OpenAPI source, Collection remove) { - final ObjectMapper mapper = Yaml.mapper(); + private OpenAPI transform(@NonNull OpenAPI source, Collection remove) { + final ObjectMapper mapper = SerializerUtils.yamlMapper(source); final JsonNode tree = mapper.valueToTree(source); if (tree instanceof ContainerNode) { removeExtensions((ContainerNode) tree, remove); } - return mapper.treeToValue(tree, OpenAPI.class); + final OpenAPI result = mapper.treeToValue(tree, OpenAPI.class); + // treeToValue builds a fresh OpenAPI, whose spec version defaults to 3.0 regardless of the source. + result.setSpecVersion(source.getSpecVersion()); + return result; } private void removeExtensions(ContainerNode node, Collection remove) { diff --git a/boat-engine/src/test/java/com/backbase/oss/boat/serializer/SerializerUtilsTests.java b/boat-engine/src/test/java/com/backbase/oss/boat/serializer/SerializerUtilsTests.java new file mode 100644 index 000000000..7e0faf19b --- /dev/null +++ b/boat-engine/src/test/java/com/backbase/oss/boat/serializer/SerializerUtilsTests.java @@ -0,0 +1,96 @@ +package com.backbase.oss.boat.serializer; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.backbase.oss.boat.loader.OpenAPILoader; +import com.backbase.oss.boat.loader.OpenAPILoaderException; +import io.swagger.v3.core.util.Yaml; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.SpecVersion; +import java.io.File; +import org.junit.jupiter.api.Test; + +class SerializerUtilsTests { + + private static final String OPENAPI_31 = "src/test/resources/openapi/openapi-3-1/openapi.yaml"; + private static final String OPENAPI_30 = "src/test/resources/openapi/extension-filter/openapi.yaml"; + + private OpenAPI load(String path) throws OpenAPILoaderException { + return OpenAPILoader.load(new File(path)); + } + + @Test + void detectsVersionFromTheParsedSpecVersion() throws OpenAPILoaderException { + assertTrue(SerializerUtils.isOpenApi31(load(OPENAPI_31))); + assertFalse(SerializerUtils.isOpenApi31(load(OPENAPI_30))); + } + + @Test + void detectsVersionFromTheOpenapiFieldWhenSpecVersionWasLost() { + // A round trip through a 3.0 mapper resets specVersion to the V30 default while leaving the header + // intact; the document must still be recognised as 3.1. + OpenAPI openAPI = new OpenAPI(); + openAPI.setSpecVersion(SpecVersion.V30); + openAPI.setOpenapi("3.1.0"); + + assertTrue(SerializerUtils.isOpenApi31(openAPI)); + } + + @Test + void treatsNullAndVersionlessDocumentsAsNotOpenApi31() { + assertFalse(SerializerUtils.isOpenApi31(null)); + assertFalse(SerializerUtils.isOpenApi31(new OpenAPI())); + } + + @Test + void keepsTheNullInNullOutContract() { + assertNull(SerializerUtils.toYamlString(null)); + assertNull(SerializerUtils.toJsonString(null)); + } + + /** + * The regression this class exists for: serialized through the 3.0 mapper, a 3.1 document keeps its + * {@code openapi: 3.1.0} header but loses {@code webhooks} entirely and degrades every 3.1-only schema + * keyword, producing an invalid hybrid document. + */ + @Test + void yamlRetainsOpenApi31Constructs() throws OpenAPILoaderException { + String yaml = SerializerUtils.toYamlString(load(OPENAPI_31)); + + assertThat(yaml, containsString("openapi: 3.1.0")); + assertThat(yaml, containsString("webhooks:")); + assertThat(yaml, containsString("thingChanged:")); + assertThat(yaml, containsString("jsonSchemaDialect:")); + assertThat(yaml, containsString("const: thing")); + assertThat(yaml, containsString("exclusiveMinimum: 0")); + assertThat(yaml, containsString("contentMediaType: application/octet-stream")); + // the "type: [string, \"null\"]" array, which the 3.0 mapper cannot represent + assertThat(yaml, containsString("- \"null\"")); + // the 3.0 mapper collapses these properties to "nickname: {}" / "kind: {}" / "payload: {}" + assertThat(yaml, not(containsString("nickname: {}"))); + assertThat(yaml, not(containsString("kind: {}"))); + assertThat(yaml, not(containsString("payload: {}"))); + } + + @Test + void jsonRetainsOpenApi31Constructs() throws OpenAPILoaderException { + String json = SerializerUtils.toJsonString(load(OPENAPI_31)); + + assertThat(json, containsString("\"webhooks\"")); + assertThat(json, containsString("\"const\" : \"thing\"")); + assertThat(json, containsString("\"jsonSchemaDialect\"")); + } + + @Test + void leavesOpenApi30SerializationUntouched() throws OpenAPILoaderException { + OpenAPI openAPI = load(OPENAPI_30); + + assertEquals(Yaml.pretty(openAPI), SerializerUtils.toYamlString(openAPI)); + } +} diff --git a/boat-engine/src/test/java/com/backbase/oss/boat/transformers/DeduplicateSchemasTransformerTests.java b/boat-engine/src/test/java/com/backbase/oss/boat/transformers/DeduplicateSchemasTransformerTests.java index 5385c9497..4cedfc84b 100644 --- a/boat-engine/src/test/java/com/backbase/oss/boat/transformers/DeduplicateSchemasTransformerTests.java +++ b/boat-engine/src/test/java/com/backbase/oss/boat/transformers/DeduplicateSchemasTransformerTests.java @@ -11,6 +11,7 @@ import io.swagger.v3.oas.models.Operation; import io.swagger.v3.oas.models.PathItem; import io.swagger.v3.oas.models.Paths; +import io.swagger.v3.oas.models.SpecVersion; import io.swagger.v3.oas.models.media.Content; import io.swagger.v3.oas.models.media.MediaType; import io.swagger.v3.oas.models.media.Schema; @@ -80,4 +81,31 @@ void leavesDistinctSchemasAlone() { assertNull(result.getComponents().getSchemas().get("First").get$ref()); assertNull(result.getComponents().getSchemas().get("Second").get$ref()); } + + /** + * Duplicate detection keys schemas on their serialized form. Serialized with the 3.0 mapper, two 3.1 + * schemas differing only in a 3.1-only keyword both flatten to {@code {}} and are wrongly merged. + */ + @Test + void doesNotMergeOpenApi31SchemasThatDifferOnlyInA31Keyword() { + Schema thing = new Schema<>(); + thing.setConst("thing"); + + Schema other = new Schema<>(); + other.setConst("other"); + + OpenAPI openAPI = new OpenAPI(SpecVersion.V31); + openAPI.setOpenapi("3.1.0"); + openAPI.setComponents(new Components() + .addSchemas("Thing", thing) + .addSchemas("Other", other)); + openAPI.setPaths(new Paths()); + + OpenAPI result = new DeduplicateSchemasTransformer().transform(openAPI, emptyMap()); + + assertEquals(2, result.getComponents().getSchemas().size(), + "Schemas with different const values are distinct and must not be merged."); + assertTrue(result.getComponents().getSchemas().containsKey("Thing")); + assertTrue(result.getComponents().getSchemas().containsKey("Other")); + } } diff --git a/boat-engine/src/test/java/com/backbase/oss/boat/transformers/ExtensionFilterTests.java b/boat-engine/src/test/java/com/backbase/oss/boat/transformers/ExtensionFilterTests.java index dfec5883e..9323b9d87 100644 --- a/boat-engine/src/test/java/com/backbase/oss/boat/transformers/ExtensionFilterTests.java +++ b/boat-engine/src/test/java/com/backbase/oss/boat/transformers/ExtensionFilterTests.java @@ -4,7 +4,9 @@ import static java.util.Collections.singletonMap; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.hasItem; import static org.hamcrest.Matchers.not; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import com.backbase.oss.boat.loader.OpenAPILoader; @@ -13,6 +15,7 @@ import java.io.File; import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.SpecVersion; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.Test; @@ -34,5 +37,35 @@ void run() throws Throwable { assertThat(s, not(containsString("x-remove"))); } + /** + * The filter round-trips the whole document through a Jackson mapper. Done with the 3.0 mapper, that + * silently strips every 3.1-only construct and resets the spec version, so a 3.1 spec came out of the + * filter downgraded even though the extension filtering itself looked correct. + */ + @Test + void retainsOpenApi31Constructs() throws Throwable { + Transformer trn = new ExtensionFilter(); + + OpenAPI api1 = OpenAPILoader.load(new File("src/test/resources/openapi/openapi-3-1/openapi.yaml")); + OpenAPI api2 = trn.transform(api1, singletonMap("remove", singleton("x-remove"))); + + assertNotNull(api2); + assertEquals(SpecVersion.V31, api2.getSpecVersion()); + assertNotNull(api2.getWebhooks()); + assertThat(api2.getWebhooks().keySet(), hasItem("thingChanged")); + + final String s = SerializerUtils.toYamlString(api2); + + assertThat(s, containsString("x-keep")); + assertThat(s, not(containsString("x-remove"))); + + assertThat(s, containsString("openapi: 3.1.0")); + assertThat(s, containsString("webhooks:")); + assertThat(s, containsString("const: thing")); + assertThat(s, containsString("exclusiveMinimum: 0")); + assertThat(s, containsString("- \"null\"")); + assertThat(s, not(containsString("nickname: {}"))); + } + } diff --git a/boat-engine/src/test/resources/openapi/openapi-3-1/openapi.yaml b/boat-engine/src/test/resources/openapi/openapi-3-1/openapi.yaml new file mode 100644 index 000000000..3fdcd68f1 --- /dev/null +++ b/boat-engine/src/test/resources/openapi/openapi-3-1/openapi.yaml @@ -0,0 +1,59 @@ +openapi: 3.1.0 +jsonSchemaDialect: https://json-schema.org/draft/2020-12/schema +x-remove: remove +x-keep: keep +info: + title: Thing API 3.1 + version: 1.0.0 + x-remove: remove + x-keep: keep +servers: + - url: http://localhost +paths: + /things: + get: + operationId: getThings + x-remove: remove + x-keep: keep + responses: + "200": + description: ok + content: + application/json: + schema: + $ref: "#/components/schemas/Thing" +webhooks: + thingChanged: + post: + operationId: thingChanged + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/Thing" + responses: + "200": + description: ok +components: + schemas: + Thing: + type: object + properties: + # 3.1 type array: dropped by the 3.0 mapper, which keeps only a single type. + nickname: + type: + - string + - "null" + # 3.1 const: has no 3.0 equivalent at all. + kind: + const: thing + # In 3.1 exclusiveMinimum is a number; in 3.0 it is a boolean companion to minimum. + theNumber: + type: integer + format: int32 + exclusiveMinimum: 0 + maximum: 10 + # 3.1 contentMediaType, replacing the 3.0 string/binary format. + payload: + type: string + contentMediaType: application/octet-stream diff --git a/boat-maven-plugin/pom.xml b/boat-maven-plugin/pom.xml index 35d817c8d..01f42ac9b 100644 --- a/boat-maven-plugin/pom.xml +++ b/boat-maven-plugin/pom.xml @@ -5,7 +5,7 @@ com.backbase.oss backbase-openapi-tools - 0.18.5-SNAPSHOT + 0.19.0-SNAPSHOT boat-maven-plugin @@ -273,6 +273,11 @@ 5.5.0 + + org.projectlombok + lombok + + @@ -293,6 +298,13 @@ 11 11 + + + org.projectlombok + lombok + ${lombok.version} + + diff --git a/boat-maven-plugin/src/main/java/com/backbase/oss/boat/GenerateMojo.java b/boat-maven-plugin/src/main/java/com/backbase/oss/boat/GenerateMojo.java index e475b9d50..a931bdb73 100644 --- a/boat-maven-plugin/src/main/java/com/backbase/oss/boat/GenerateMojo.java +++ b/boat-maven-plugin/src/main/java/com/backbase/oss/boat/GenerateMojo.java @@ -25,6 +25,7 @@ import static org.openapitools.codegen.config.CodegenConfiguratorUtils.applyTypeMappingsKvp; import static org.openapitools.codegen.config.CodegenConfiguratorUtils.applyTypeMappingsKvpList; +import com.backbase.oss.boat.serializer.SerializerUtils; import com.backbase.oss.boat.transformers.Bundler; import com.backbase.oss.boat.transformers.DeduplicateSchemasTransformer; import com.backbase.oss.boat.transformers.DereferenceComponentsPropertiesTransformer; @@ -33,7 +34,6 @@ import com.google.common.io.ByteSource; import com.google.common.io.CharSource; import com.google.common.io.Files; -import io.swagger.v3.core.util.Yaml; import io.swagger.v3.parser.core.models.AuthorizationValue; import io.swagger.v3.parser.util.ClasspathHelper; import java.io.File; @@ -948,13 +948,13 @@ public void execute() throws MojoExecutionException, MojoFailureException { if (unAlias) { new UnAliasTransformer().transform(input.getOpenAPI(), emptyMap()); if(writeDebugFiles) { - java.nio.file.Files.write(new File(output, "openapi-unaliased.yaml").toPath(), Yaml.pretty(input.getOpenAPI()).getBytes(StandardCharsets.UTF_8)); + java.nio.file.Files.write(new File(output, "openapi-unaliased.yaml").toPath(), SerializerUtils.toYamlString(input.getOpenAPI()).getBytes(StandardCharsets.UTF_8)); } } if (dereferenceComponents) { new DereferenceComponentsPropertiesTransformer().transform(input.getOpenAPI(), emptyMap()); if(writeDebugFiles) { - java.nio.file.Files.write(new File(output, "openapi-dereferenced.yaml").toPath(), Yaml.pretty(input.getOpenAPI()).getBytes(StandardCharsets.UTF_8)); + java.nio.file.Files.write(new File(output, "openapi-dereferenced.yaml").toPath(), SerializerUtils.toYamlString(input.getOpenAPI()).getBytes(StandardCharsets.UTF_8)); } } @@ -966,7 +966,7 @@ public void execute() throws MojoExecutionException, MojoFailureException { } if(writeDebugFiles) { - java.nio.file.Files.write(new File(output, "openapi-bundled.yaml").toPath(), Yaml.pretty(input.getOpenAPI()).getBytes(StandardCharsets.UTF_8)); + java.nio.file.Files.write(new File(output, "openapi-bundled.yaml").toPath(), SerializerUtils.toYamlString(input.getOpenAPI()).getBytes(StandardCharsets.UTF_8)); } } diff --git a/boat-maven-plugin/src/test/java/com/backbase/oss/boat/BundleMojoTest.java b/boat-maven-plugin/src/test/java/com/backbase/oss/boat/BundleMojoTest.java index 1a0a04553..92071af81 100644 --- a/boat-maven-plugin/src/test/java/com/backbase/oss/boat/BundleMojoTest.java +++ b/boat-maven-plugin/src/test/java/com/backbase/oss/boat/BundleMojoTest.java @@ -2,10 +2,12 @@ import com.backbase.oss.boat.loader.OpenAPILoader; import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.SpecVersion; import io.swagger.v3.oas.models.info.Info; import java.io.File; import java.io.IOException; import java.net.URL; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; import java.util.Set; @@ -287,4 +289,36 @@ protected File getFile(String name) { assert resource != null; return new File(resource.getFile()); } + + /** + * End-to-end guard for OpenAPI 3.1 support: bundling used to write the document back out through the + * 3.0 serializer, producing a file that still declared {@code openapi: 3.1.0} while {@code webhooks} and + * every 3.1-only schema keyword had been silently stripped. + */ + @Test + @SneakyThrows + void testBundleOpenApi31() { + File output = new File("target/test-bundle-openapi-3-1.yaml"); + Files.deleteIfExists(output.toPath()); + + BundleMojo mojo = new BundleMojo(); + mojo.setInput(new File(getClass().getResource("/oas-examples/petstore-3.1.yaml").getFile())); + mojo.setOutput(output); + mojo.execute(); + + assertTrue(output.exists()); + String bundled = new String(Files.readAllBytes(output.toPath()), StandardCharsets.UTF_8); + + assertTrue(bundled.contains("openapi: 3.1.0"), "The spec version must be preserved."); + assertTrue(bundled.contains("webhooks:"), "3.1 webhooks must survive bundling."); + assertTrue(bundled.contains("petAdded:")); + assertTrue(bundled.contains("const: pet"), "3.1 const must survive bundling."); + assertTrue(bundled.contains("exclusiveMinimum: 0"), "3.1 numeric exclusiveMinimum must survive bundling."); + assertTrue(bundled.contains("- \"null\""), "3.1 type arrays must survive bundling."); + assertFalse(bundled.contains("nickname: {}"), "3.1 schemas must not be flattened by the 3.0 mapper."); + + // and the bundled result must still be loadable as 3.1 + OpenAPI reloaded = OpenAPILoader.load(output); + assertEquals(SpecVersion.V31, reloaded.getSpecVersion()); + } } diff --git a/boat-maven-plugin/src/test/resources/oas-examples/petstore-3.1.yaml b/boat-maven-plugin/src/test/resources/oas-examples/petstore-3.1.yaml new file mode 100644 index 000000000..d07547abf --- /dev/null +++ b/boat-maven-plugin/src/test/resources/oas-examples/petstore-3.1.yaml @@ -0,0 +1,53 @@ +openapi: 3.1.0 +jsonSchemaDialect: https://json-schema.org/draft/2020-12/schema +info: + title: Petstore 3.1 + version: 1.0.0 +servers: + - url: http://localhost:4010 +paths: + /pets: + get: + operationId: listPets + responses: + "200": + description: A list of pets. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Pet" +webhooks: + petAdded: + post: + operationId: petAdded + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + responses: + "200": + description: Acknowledged. +components: + schemas: + Pet: + type: object + required: + - id + properties: + id: + type: integer + format: int64 + nickname: + type: + - string + - "null" + kind: + const: pet + age: + type: integer + format: int32 + exclusiveMinimum: 0 + maximum: 30 diff --git a/boat-quay/boat-quay-lint/pom.xml b/boat-quay/boat-quay-lint/pom.xml index 2f78c3753..340611bd2 100644 --- a/boat-quay/boat-quay-lint/pom.xml +++ b/boat-quay/boat-quay-lint/pom.xml @@ -5,7 +5,7 @@ com.backbase.oss boat-quay - 0.18.5-SNAPSHOT + 0.19.0-SNAPSHOT boat-quay-lint diff --git a/boat-quay/boat-quay-lint/src/main/java/com/backbase/oss/boat/quay/BoatLinter.java b/boat-quay/boat-quay-lint/src/main/java/com/backbase/oss/boat/quay/BoatLinter.java index 757b245a3..8bc02ab7b 100644 --- a/boat-quay/boat-quay-lint/src/main/java/com/backbase/oss/boat/quay/BoatLinter.java +++ b/boat-quay/boat-quay-lint/src/main/java/com/backbase/oss/boat/quay/BoatLinter.java @@ -6,6 +6,7 @@ import com.backbase.oss.boat.quay.model.BoatLintReport; import com.backbase.oss.boat.quay.model.BoatLintRule; import com.backbase.oss.boat.quay.model.BoatViolation; +import com.backbase.oss.boat.serializer.SerializerUtils; import com.typesafe.config.Config; import io.swagger.v3.oas.models.OpenAPI; import java.io.File; @@ -15,9 +16,12 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Set; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import org.apache.commons.io.IOUtils; @@ -33,6 +37,16 @@ @Slf4j public class BoatLinter { + /** + * Rules that cannot be evaluated against an OpenAPI 3.1 document, and whose violations are therefore + * dropped for such documents. + * + *

Zally's {@code 219} ({@code UseOpenApiRule}) validates every OpenAPI 3 document against the OAS + * 3.0 JSON schema, and only one schema can be configured at a time. On a 3.1 document every + * violation it reports is a false positive. + */ + private static final Set RULES_WITHOUT_OPENAPI_31_SUPPORT = Collections.singleton("219"); + private final ApiValidator validator; private final URI documentationBaseUrl = URI.create("https://backbase.github.io/backbase-openapi-tools/rules.md"); @@ -78,13 +92,17 @@ private Path getFilePath(File inputFile) { } public BoatLintReport lint(String openApiContent) throws OpenAPILoaderException { + // OpenAPILoader.parse throws rather than returning null; stating that here keeps the document + // non-null for the null-tolerant SerializerUtils calls below. + OpenAPI openAPI = Objects.requireNonNull(OpenAPILoader.parse(openApiContent)); + boolean openApi31 = SerializerUtils.isOpenApi31(openAPI); + List validate = validator.validate(openApiContent, rulesPolicy, null); List violations = validate.stream() + .filter(result -> !(openApi31 && RULES_WITHOUT_OPENAPI_31_SUPPORT.contains(result.getId()))) .map(this::transformResult) .collect(Collectors.toList()); - OpenAPI openAPI = OpenAPILoader.parse(openApiContent); - BoatLintReport boatLintReport = new BoatLintReport(); boatLintReport.setOpenApi(openApiContent); boatLintReport.setAvailableRules(getAvailableRules()); diff --git a/boat-quay/boat-quay-lint/src/test/java/com/backbase/oss/boat/quay/BoatLinterTests.java b/boat-quay/boat-quay-lint/src/test/java/com/backbase/oss/boat/quay/BoatLinterTests.java index 9639fefa2..71a9edc1c 100644 --- a/boat-quay/boat-quay-lint/src/test/java/com/backbase/oss/boat/quay/BoatLinterTests.java +++ b/boat-quay/boat-quay-lint/src/test/java/com/backbase/oss/boat/quay/BoatLinterTests.java @@ -79,4 +79,40 @@ void ruleManager() { assertFalse(availableRules.isEmpty()); } + + /** + * Zally's rule 219 validates every OpenAPI 3 document against the OAS 3.0 JSON schema, so on a 3.1 + * document it reports violations that are false positives by construction. They must not reach the + * report. + */ + @Test + void doesNotReportSchemaViolationsThatCannotApplyToOpenApi31() throws IOException, OpenAPILoaderException { + String openApiContents = IOUtils.resourceToString("/openapi/openapi-3-1/openapi.yaml", Charset.defaultCharset()); + + BoatLintReport boatLintReport = boatLinter.lint(openApiContents); + + assertFalse(hasViolationOfRule(boatLintReport, "219"), + "Rule 219 cannot validate a 3.1 document and must be skipped for one."); + assertFalse(hasViolationOfRule(boatLintReport, "M0012"), + "3.1.x must be an accepted OpenAPI version."); + } + + @Test + void stillReportsSchemaViolationsForOpenApi30() throws IOException, OpenAPILoaderException { + String openApiContents = IOUtils.resourceToString("/openapi/presentation-client-api/openapi.yaml", Charset.defaultCharset()); + + BoatLintReport boatLintReport = boatLinter.lint(openApiContents); + + assertTrue(boatLintReport.hasViolations()); + // 3.0 documents keep going through every rule, rule 219 included. + assertTrue(boatLintReport.getAvailableRules().stream().anyMatch(rule -> "219".equals(rule.getId())), + "Rule 219 must remain registered for 3.0 documents."); + } + + private boolean hasViolationOfRule(BoatLintReport report, String ruleId) { + return report.getViolations().stream() + .map(BoatViolation::getRule) + .filter(java.util.Objects::nonNull) + .anyMatch(rule -> ruleId.equals(rule.getId())); + } } diff --git a/boat-quay/boat-quay-rules/pom.xml b/boat-quay/boat-quay-rules/pom.xml index 93bbb261a..2d45b4401 100644 --- a/boat-quay/boat-quay-rules/pom.xml +++ b/boat-quay/boat-quay-rules/pom.xml @@ -5,7 +5,7 @@ com.backbase.oss boat-quay - 0.18.5-SNAPSHOT + 0.19.0-SNAPSHOT boat-quay-rules diff --git a/boat-quay/boat-quay-rules/src/main/resources/boat.conf b/boat-quay/boat-quay-rules/src/main/resources/boat.conf index 68a89b4f9..abe78f739 100644 --- a/boat-quay/boat-quay-rules/src/main/resources/boat.conf +++ b/boat-quay/boat-quay-rules/src/main/resources/boat.conf @@ -67,7 +67,7 @@ StringPropertyLengthBoundsRule { } OpenApiVersionRule { - openApiVersions: [ 3.0.3, 3.0.4 ] + openApiVersions: [ 3.0.3, 3.0.4, 3.1.0, 3.1.1, 3.1.2 ] } ExtraRuleAnnotations { diff --git a/boat-quay/boat-quay-rules/src/test/kotlin/com/backbase/oss/boat/quay/ruleset/OpenApiVersionRuleTest.kt b/boat-quay/boat-quay-rules/src/test/kotlin/com/backbase/oss/boat/quay/ruleset/OpenApiVersionRuleTest.kt index 29a3ad4f3..56d5abae5 100644 --- a/boat-quay/boat-quay-rules/src/test/kotlin/com/backbase/oss/boat/quay/ruleset/OpenApiVersionRuleTest.kt +++ b/boat-quay/boat-quay-rules/src/test/kotlin/com/backbase/oss/boat/quay/ruleset/OpenApiVersionRuleTest.kt @@ -10,6 +10,30 @@ class OpenApiVersionRuleTest { private val cut = OpenApiVersionRule(rulesConfig) + /** + * Builds a context for a given OpenAPI version. Only versions swagger-parser can actually represent + * (3.0.x and 3.1.x) can be used here: for anything else the parser yields no document at all, so the + * spec never reaches this rule. + */ + private fun contextFor(version: String) = DefaultContextFactory().getOpenApiContext( + """ + openapi: $version + info: + title: Thing API + version: 1.0.0 + components: + schemas: + Thing: + type: object + properties: + theNumber: + type: integer + format: int32 + minimum: 0 + maximum: 10 + """.trimIndent() + ) + @Test fun `check open api version return no validations`() { @Language("YAML") @@ -39,5 +63,27 @@ class OpenApiVersionRuleTest { .isEmpty() } + @Test + fun `accepts every explicitly allowed 3 0 version`() { + listOf("3.0.3", "3.0.4").forEach { version -> + ZallyAssertions.assertThat(cut.validate(contextFor(version))).isEmpty() + } + } + + @Test + fun `accepts every explicitly allowed 3 1 version`() { + listOf("3.1.0", "3.1.1", "3.1.2").forEach { version -> + ZallyAssertions.assertThat(cut.validate(contextFor(version))).isEmpty() + } + } -} \ No newline at end of file + @Test + fun `reports a violation for a version that is not allowed`() { + // 3.1.3 pins the boundary: the allowed versions are an explicit list, not the whole 3.1 line. + listOf("3.0.0", "3.0.1", "3.0.2", "3.1.3").forEach { version -> + ZallyAssertions + .assertThat(cut.validate(contextFor(version))) + .pointersEqualTo("/openapi") + } + } +} diff --git a/boat-quay/boat-quay-rules/src/test/resources/reference.conf b/boat-quay/boat-quay-rules/src/test/resources/reference.conf index 855b64e7d..345cbe478 100644 --- a/boat-quay/boat-quay-rules/src/test/resources/reference.conf +++ b/boat-quay/boat-quay-rules/src/test/resources/reference.conf @@ -9,7 +9,7 @@ StringPropertyLengthBoundsRule { } OpenApiVersionRule { - openApiVersions: [ 3.0.3, 3.0.4 ] + openApiVersions: [ 3.0.3, 3.0.4, 3.1.0, 3.1.1, 3.1.2 ] } NoReservedWordsChecker { diff --git a/boat-quay/openapi-rules.yml b/boat-quay/openapi-rules.yml index 389c1a0b1..0a7387098 100644 --- a/boat-quay/openapi-rules.yml +++ b/boat-quay/openapi-rules.yml @@ -1,14 +1,14 @@ extends: spectral:oas rules: openapi-spec-version: - description: "OpenAPI specification version must be 3.0.3." + description: "OpenAPI specification version must be 3.0.3, 3.0.4, 3.1.0, 3.1.1 or 3.1.2." severity: error given: "$" then: field: openapi function: pattern functionOptions: - match: "3.0.3" + match: "^3\\.(0\\.[34]|1\\.[0-2])$" no-license-allowed: description: "No license information allowed because it's covered by the License Agreement we already negotiate with customers." given: "$.info" diff --git a/boat-quay/pom.xml b/boat-quay/pom.xml index f61f874ba..9ecd6bfe4 100644 --- a/boat-quay/pom.xml +++ b/boat-quay/pom.xml @@ -5,7 +5,7 @@ com.backbase.oss backbase-openapi-tools - 0.18.5-SNAPSHOT + 0.19.0-SNAPSHOT diff --git a/boat-scaffold/pom.xml b/boat-scaffold/pom.xml index 741a9e121..6f9d1cc86 100644 --- a/boat-scaffold/pom.xml +++ b/boat-scaffold/pom.xml @@ -5,7 +5,7 @@ com.backbase.oss backbase-openapi-tools - 0.18.5-SNAPSHOT + 0.19.0-SNAPSHOT boat-scaffold @@ -107,7 +107,7 @@ com.backbase.oss boat-trail-resources - 0.18.5-SNAPSHOT + 0.19.0-SNAPSHOT test @@ -256,6 +256,20 @@ + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + ${lombok.version} + + + + diff --git a/boat-trail-resources/pom.xml b/boat-trail-resources/pom.xml index 7dd4b2b32..d7f46dab5 100644 --- a/boat-trail-resources/pom.xml +++ b/boat-trail-resources/pom.xml @@ -5,7 +5,7 @@ com.backbase.oss backbase-openapi-tools - 0.18.5-SNAPSHOT + 0.19.0-SNAPSHOT boat-trail-resources diff --git a/boat-trail-resources/src/main/resources/openapi/openapi-3-1/openapi.yaml b/boat-trail-resources/src/main/resources/openapi/openapi-3-1/openapi.yaml new file mode 100644 index 000000000..e97022ca9 --- /dev/null +++ b/boat-trail-resources/src/main/resources/openapi/openapi-3-1/openapi.yaml @@ -0,0 +1,52 @@ +openapi: 3.1.0 +jsonSchemaDialect: https://json-schema.org/draft/2020-12/schema +info: + title: Presentation Thing Service API + version: 1.0.0 +servers: + - url: http://localhost:4010 +paths: + /client-api/things: + get: + operationId: getThings + tags: + - Things + responses: + "200": + description: The things. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Thing" +webhooks: + thingChanged: + post: + operationId: thingChanged + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/Thing" + responses: + "200": + description: Acknowledged. +components: + schemas: + Thing: + type: object + properties: + # 3.1 constructs that the OAS 3.0 JSON schema used by zally rule 219 cannot validate. + nickname: + type: + - string + - "null" + maxLength: 50 + kind: + const: thing + theNumber: + type: integer + format: int32 + exclusiveMinimum: 0 + maximum: 10 diff --git a/pom.xml b/pom.xml index b67db8bf1..3cb100b4a 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ com.backbase.oss backbase-openapi-tools - 0.18.5-SNAPSHOT + 0.19.0-SNAPSHOT pom Backbase Open Api Tools is a collection of tools to work with Open API diff --git a/tests/pom.xml b/tests/pom.xml index dee979944..daa1f0447 100644 --- a/tests/pom.xml +++ b/tests/pom.xml @@ -5,7 +5,7 @@ com.backbase.oss backbase-openapi-tools - 0.18.5-SNAPSHOT + 0.19.0-SNAPSHOT tests