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.ossbackbase-openapi-tools
- 0.18.5-SNAPSHOT
+ 0.19.0-SNAPSHOTboat-maven-plugin
@@ -273,6 +273,11 @@
5.5.0
+
+ org.projectlombok
+ lombok
+
+
@@ -293,6 +298,13 @@
1111
+
+
+ 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.ossboat-quay
- 0.18.5-SNAPSHOT
+ 0.19.0-SNAPSHOTboat-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.ossboat-quay
- 0.18.5-SNAPSHOT
+ 0.19.0-SNAPSHOTboat-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.ossbackbase-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.ossbackbase-openapi-tools
- 0.18.5-SNAPSHOT
+ 0.19.0-SNAPSHOTboat-scaffold
@@ -107,7 +107,7 @@
com.backbase.ossboat-trail-resources
- 0.18.5-SNAPSHOT
+ 0.19.0-SNAPSHOTtest
@@ -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.ossbackbase-openapi-tools
- 0.18.5-SNAPSHOT
+ 0.19.0-SNAPSHOTboat-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.ossbackbase-openapi-tools
- 0.18.5-SNAPSHOT
+ 0.19.0-SNAPSHOTpomBackbase 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.ossbackbase-openapi-tools
- 0.18.5-SNAPSHOT
+ 0.19.0-SNAPSHOTtests