From e5774fe5571bee26e5b931b4df065dbc859a9c5d Mon Sep 17 00:00:00 2001 From: Afsin Kapusuzoglu Date: Fri, 28 Aug 2026 10:01:45 +0200 Subject: [PATCH 1/5] chore: add lombok to fix local build --- boat-engine/pom.xml | 19 +++++++++++++++++++ boat-maven-plugin/pom.xml | 12 ++++++++++++ boat-scaffold/pom.xml | 14 ++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/boat-engine/pom.xml b/boat-engine/pom.xml index 8855f94cc..c052e6751 100644 --- a/boat-engine/pom.xml +++ b/boat-engine/pom.xml @@ -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-maven-plugin/pom.xml b/boat-maven-plugin/pom.xml index 35d817c8d..68e5c517b 100644 --- a/boat-maven-plugin/pom.xml +++ b/boat-maven-plugin/pom.xml @@ -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-scaffold/pom.xml b/boat-scaffold/pom.xml index 741a9e121..64fe189db 100644 --- a/boat-scaffold/pom.xml +++ b/boat-scaffold/pom.xml @@ -256,6 +256,20 @@ + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + ${lombok.version} + + + + From c5fb025f0c2a98fab1cbca9398685098f1df9993 Mon Sep 17 00:00:00 2001 From: Afsin Kapusuzoglu Date: Fri, 28 Aug 2026 10:35:02 +0200 Subject: [PATCH 2/5] feat: support OpenAPI 3.1.x alongside 3.0.x The dependency stack was already 3.1-capable (swagger-parser 2.1.46 detects 3.1 and sets OpenAPI.specVersion, swagger-core 2.2.54 ships Yaml31/Json31 with the 3.1 Jackson mixins), but nothing used it. A 3.1 spec parsed into a model that knew it was 3.1 and was then written back out through the 3.0 mapper, yielding a document with an `openapi: 3.1.0` header and a 3.0-serialized body: `webhooks` dropped entirely, and `const`, type arrays and `contentMediaType` flattened to `{}`. An invalid hybrid, with no warning. Serialization: - SerializerUtils.toYamlString now branches on the spec version, and gains isOpenApi31, toJsonString and yamlMapper/jsonMapper accessors. Version detection prefers getSpecVersion() and falls back to the `openapi` header, because `new OpenAPI()` defaults to V30 and a document that lost its spec version in an earlier round trip must still be recognised. This one change covers the six mojos that already funnel through toYamlString. - ExtensionFilter and DeduplicateSchemasTransformer round-tripped the whole document through the 3.0 mapper, losing 3.1 keywords in memory; they now use the version-appropriate mapper. ExtensionFilter also restores specVersion, since treeToValue builds a fresh OpenAPI defaulting to V30. - GenerateMojo's three debug dumps went straight to Yaml.pretty, bypassing SerializerUtils; they now go through it. Linting: - OpenApiVersionRule (M0012) matched an exact allowlist. It now also accepts configured regex patterns, so boat.conf and the test reference.conf allow every 3.1 patch release without enumerating them. The config key is read via hasPath, as getStringList throws on a missing path and consumers may supply a boat.conf predating the key. - Zally's rule 219 validates every OpenAPI 3 document against the OAS 3.0 JSON schema and cannot be pointed at two schemas at once, so on a 3.1 document every violation it reports is a false positive. BoatLinter now drops those, reusing the parse it already performed. - The (unreferenced) Spectral ruleset is anchored to ^3\.(0\.[34]|1\.\d+)$. This also aligns 3.0 with boat.conf, which has allowed 3.0.4 all along. Tests add the first 3.1 fixtures in the repo, covering webhooks, type arrays, const, numeric exclusiveMinimum and contentMediaType, and were each checked to fail without their fix. Co-Authored-By: Claude Opus 5 (1M context) --- .../oss/boat/serializer/SerializerUtils.java | 68 +++++++++++++ .../DeduplicateSchemasTransformer.java | 22 +++-- .../boat/transformers/ExtensionFilter.java | 9 +- .../boat/serializer/SerializerUtilsTests.java | 96 +++++++++++++++++++ .../DeduplicateSchemasTransformerTests.java | 28 ++++++ .../transformers/ExtensionFilterTests.java | 33 +++++++ .../openapi/openapi-3-1/openapi.yaml | 59 ++++++++++++ .../com/backbase/oss/boat/GenerateMojo.java | 8 +- .../com/backbase/oss/boat/BundleMojoTest.java | 34 +++++++ .../resources/oas-examples/petstore-3.1.yaml | 53 ++++++++++ .../backbase/oss/boat/quay/BoatLinter.java | 19 +++- .../oss/boat/quay/BoatLinterTests.java | 36 +++++++ .../boat/quay/ruleset/OpenApiVersionRule.kt | 38 ++++++-- .../src/main/resources/boat.conf | 2 + .../quay/ruleset/OpenApiVersionRuleTest.kt | 47 ++++++++- .../src/test/resources/reference.conf | 2 + boat-quay/openapi-rules.yml | 4 +- .../openapi/openapi-3-1/openapi.yaml | 52 ++++++++++ 18 files changed, 582 insertions(+), 28 deletions(-) create mode 100644 boat-engine/src/test/java/com/backbase/oss/boat/serializer/SerializerUtilsTests.java create mode 100644 boat-engine/src/test/resources/openapi/openapi-3-1/openapi.yaml create mode 100644 boat-maven-plugin/src/test/resources/oas-examples/petstore-3.1.yaml create mode 100644 boat-trail-resources/src/main/resources/openapi/openapi-3-1/openapi.yaml 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..63805d740 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; @@ -50,14 +50,17 @@ public class ExtensionFilter implements Transformer { @SneakyThrows private OpenAPI transform(OpenAPI source, Collection remove) { - final ObjectMapper mapper = Yaml.mapper(); + 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/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/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..a1ce355f6 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,11 @@ 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.Set; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import org.apache.commons.io.IOUtils; @@ -33,6 +36,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 +91,15 @@ private Path getFilePath(File inputFile) { } public BoatLintReport lint(String openApiContent) throws OpenAPILoaderException { + OpenAPI openAPI = 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/src/main/kotlin/com/backbase/oss/boat/quay/ruleset/OpenApiVersionRule.kt b/boat-quay/boat-quay-rules/src/main/kotlin/com/backbase/oss/boat/quay/ruleset/OpenApiVersionRule.kt index 2462e8ab8..5c5a5718b 100644 --- a/boat-quay/boat-quay-rules/src/main/kotlin/com/backbase/oss/boat/quay/ruleset/OpenApiVersionRule.kt +++ b/boat-quay/boat-quay-rules/src/main/kotlin/com/backbase/oss/boat/quay/ruleset/OpenApiVersionRule.kt @@ -16,16 +16,42 @@ class OpenApiVersionRule(config: Config) { .getStringList("OpenApiVersionRule.openApiVersions") .toList() + /** + * Version patterns accepted in addition to the exact [openApiVersions] list, so that a whole minor line + * (e.g. every 3.1.x patch release) can be allowed without enumerating each patch version. + * + * Read defensively: the key is absent from configurations written before it was introduced, and + * `getStringList` throws on a missing path. + */ + private val openApiVersionPatterns = if (config.hasPath("OpenApiVersionRule.openApiVersionPatterns")) { + config.getStringList("OpenApiVersionRule.openApiVersionPatterns").map { it.toRegex() } + } else { + emptyList() + } + @Check(Severity.MUST) fun validate(context: Context): List { - val version = context.api.openapi; + if (!context.isOpenAPI3()) { + return emptyList() + } + + val version = context.api.openapi - return when { - !context.isOpenAPI3() -> emptyList() - context.isOpenAPI3() && !openApiVersions.contains(version) -> - listOf(Violation("OpenAPI specification version must be $openApiVersions. It's now set to `$version`" , "/openapi".toJsonPointer())) - else -> emptyList() + return if (isAccepted(version)) { + emptyList() + } else { + listOf(Violation( + "OpenAPI specification version must be one of $openApiVersions" + + "${patternsSuffix()}. It's now set to `$version`", + "/openapi".toJsonPointer())) } } + + private fun isAccepted(version: String?): Boolean = + version != null && (openApiVersions.contains(version) + || openApiVersionPatterns.any { version.matches(it) }) + + private fun patternsSuffix(): String = + if (openApiVersionPatterns.isEmpty()) "" else " or match one of $openApiVersionPatterns" } 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..955a240db 100644 --- a/boat-quay/boat-quay-rules/src/main/resources/boat.conf +++ b/boat-quay/boat-quay-rules/src/main/resources/boat.conf @@ -68,6 +68,8 @@ StringPropertyLengthBoundsRule { OpenApiVersionRule { openApiVersions: [ 3.0.3, 3.0.4 ] + # Accepts every 3.1 patch release without having to enumerate them. + openApiVersionPatterns: [ "3\\.1\\.\\d+" ] } 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..abb8b537b 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,26 @@ 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 any 3 1 patch 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 3 0 version that is not allowed`() { + listOf("3.0.0", "3.0.1", "3.0.2").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..86a9aceff 100644 --- a/boat-quay/boat-quay-rules/src/test/resources/reference.conf +++ b/boat-quay/boat-quay-rules/src/test/resources/reference.conf @@ -10,6 +10,8 @@ StringPropertyLengthBoundsRule { OpenApiVersionRule { openApiVersions: [ 3.0.3, 3.0.4 ] + # Accepts every 3.1 patch release without having to enumerate them. + openApiVersionPatterns: [ "3\\.1\\.\\d+" ] } NoReservedWordsChecker { diff --git a/boat-quay/openapi-rules.yml b/boat-quay/openapi-rules.yml index 389c1a0b1..0cbc60145 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 or 3.1.x." severity: error given: "$" then: field: openapi function: pattern functionOptions: - match: "3.0.3" + match: "^3\\.(0\\.[34]|1\\.\\d+)$" 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-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 From 3f3f174267d93d59696448e6711ab97a1b7ba44d Mon Sep 17 00:00:00 2001 From: Afsin Kapusuzoglu Date: Fri, 28 Aug 2026 13:00:09 +0200 Subject: [PATCH 3/5] chore: bump version to 0.19.0-SNAPSHOT Co-Authored-By: Claude Opus 5 (1M context) --- boat-engine/pom.xml | 2 +- boat-maven-plugin/pom.xml | 2 +- boat-quay/boat-quay-lint/pom.xml | 2 +- boat-quay/boat-quay-rules/pom.xml | 2 +- boat-quay/pom.xml | 2 +- boat-scaffold/pom.xml | 4 ++-- boat-trail-resources/pom.xml | 2 +- pom.xml | 2 +- tests/pom.xml | 2 +- 9 files changed, 10 insertions(+), 10 deletions(-) diff --git a/boat-engine/pom.xml b/boat-engine/pom.xml index c052e6751..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 diff --git a/boat-maven-plugin/pom.xml b/boat-maven-plugin/pom.xml index 68e5c517b..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 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-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/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 64fe189db..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 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/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 From beff0f0964f6c1800eb33e1b8644c0aec96c3053 Mon Sep 17 00:00:00 2001 From: Afsin Kapusuzoglu Date: Fri, 28 Aug 2026 13:19:26 +0200 Subject: [PATCH 4/5] refactor: allow 3.1.0, 3.1.1 and 3.1.2 explicitly instead of by regex The accepted OpenAPI versions were a 3.0 allowlist plus a configurable regex pattern list that matched the whole 3.1 line. Enumerating the three released 3.1 versions instead keeps the rule's original semantics -- exact membership of one configured list -- so OpenApiVersionRule needs no logic of its own and reverts to being byte-identical to the version before 3.1 support was added. A new 3.1 patch release now requires a one-line config change, which is the same maintenance already accepted for the 3.0 versions. The Spectral ruleset is narrowed to match. Its `pattern` function only takes a regex, so that stays one, but bounded to the same five versions rather than open-ended. The rule test gains 3.1.3 as a negative case to pin the boundary: it confirms the allowed versions are an explicit list rather than the whole 3.1 line, and that such a version reaches the rule at all rather than being rejected earlier by swagger-parser, which accepts any 3.1.x prefix. Co-Authored-By: Claude Opus 5 (1M context) --- .../boat/quay/ruleset/OpenApiVersionRule.kt | 38 +++---------------- .../src/main/resources/boat.conf | 4 +- .../quay/ruleset/OpenApiVersionRuleTest.kt | 7 ++-- .../src/test/resources/reference.conf | 4 +- boat-quay/openapi-rules.yml | 4 +- 5 files changed, 14 insertions(+), 43 deletions(-) diff --git a/boat-quay/boat-quay-rules/src/main/kotlin/com/backbase/oss/boat/quay/ruleset/OpenApiVersionRule.kt b/boat-quay/boat-quay-rules/src/main/kotlin/com/backbase/oss/boat/quay/ruleset/OpenApiVersionRule.kt index 5c5a5718b..2462e8ab8 100644 --- a/boat-quay/boat-quay-rules/src/main/kotlin/com/backbase/oss/boat/quay/ruleset/OpenApiVersionRule.kt +++ b/boat-quay/boat-quay-rules/src/main/kotlin/com/backbase/oss/boat/quay/ruleset/OpenApiVersionRule.kt @@ -16,42 +16,16 @@ class OpenApiVersionRule(config: Config) { .getStringList("OpenApiVersionRule.openApiVersions") .toList() - /** - * Version patterns accepted in addition to the exact [openApiVersions] list, so that a whole minor line - * (e.g. every 3.1.x patch release) can be allowed without enumerating each patch version. - * - * Read defensively: the key is absent from configurations written before it was introduced, and - * `getStringList` throws on a missing path. - */ - private val openApiVersionPatterns = if (config.hasPath("OpenApiVersionRule.openApiVersionPatterns")) { - config.getStringList("OpenApiVersionRule.openApiVersionPatterns").map { it.toRegex() } - } else { - emptyList() - } - @Check(Severity.MUST) fun validate(context: Context): List { - if (!context.isOpenAPI3()) { - return emptyList() - } - - val version = context.api.openapi + val version = context.api.openapi; - return if (isAccepted(version)) { - emptyList() - } else { - listOf(Violation( - "OpenAPI specification version must be one of $openApiVersions" + - "${patternsSuffix()}. It's now set to `$version`", - "/openapi".toJsonPointer())) + return when { + !context.isOpenAPI3() -> emptyList() + context.isOpenAPI3() && !openApiVersions.contains(version) -> + listOf(Violation("OpenAPI specification version must be $openApiVersions. It's now set to `$version`" , "/openapi".toJsonPointer())) + else -> emptyList() } } - - private fun isAccepted(version: String?): Boolean = - version != null && (openApiVersions.contains(version) - || openApiVersionPatterns.any { version.matches(it) }) - - private fun patternsSuffix(): String = - if (openApiVersionPatterns.isEmpty()) "" else " or match one of $openApiVersionPatterns" } 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 955a240db..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,9 +67,7 @@ StringPropertyLengthBoundsRule { } OpenApiVersionRule { - openApiVersions: [ 3.0.3, 3.0.4 ] - # Accepts every 3.1 patch release without having to enumerate them. - openApiVersionPatterns: [ "3\\.1\\.\\d+" ] + 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 abb8b537b..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 @@ -71,15 +71,16 @@ class OpenApiVersionRuleTest { } @Test - fun `accepts any 3 1 patch version`() { + 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() } } @Test - fun `reports a violation for a 3 0 version that is not allowed`() { - listOf("3.0.0", "3.0.1", "3.0.2").forEach { version -> + 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 86a9aceff..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,9 +9,7 @@ StringPropertyLengthBoundsRule { } OpenApiVersionRule { - openApiVersions: [ 3.0.3, 3.0.4 ] - # Accepts every 3.1 patch release without having to enumerate them. - openApiVersionPatterns: [ "3\\.1\\.\\d+" ] + 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 0cbc60145..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, 3.0.4 or 3.1.x." + 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\\.[34]|1\\.\\d+)$" + 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" From 099ee0eee9f82ad1ade1f7f21f8082d1960dc8c2 Mon Sep 17 00:00:00 2001 From: Afsin Kapusuzoglu Date: Fri, 28 Aug 2026 14:59:46 +0200 Subject: [PATCH 5/5] fix: address Sonar NPE findings around the spec-version helpers SerializerUtils.isOpenApi31 and yamlMapper accept a null document by design, which makes Sonar's dataflow analysis carry a "may be null" state into their callers and report S2259 on later dereferences of documents that are in fact never null. Make the non-null contract explicit at both call sites instead of loosening the null-tolerant helpers, which ValidateMojo relies on. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/backbase/oss/boat/transformers/ExtensionFilter.java | 2 +- .../src/main/java/com/backbase/oss/boat/quay/BoatLinter.java | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) 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 63805d740..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 @@ -49,7 +49,7 @@ public class ExtensionFilter implements Transformer { } @SneakyThrows - private OpenAPI transform(OpenAPI source, Collection remove) { + private OpenAPI transform(@NonNull OpenAPI source, Collection remove) { final ObjectMapper mapper = SerializerUtils.yamlMapper(source); final JsonNode tree = mapper.valueToTree(source); 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 a1ce355f6..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 @@ -20,6 +20,7 @@ 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; @@ -91,7 +92,9 @@ private Path getFilePath(File inputFile) { } public BoatLintReport lint(String openApiContent) throws OpenAPILoaderException { - OpenAPI openAPI = OpenAPILoader.parse(openApiContent); + // 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);