Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion boat-engine/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<parent>
<groupId>com.backbase.oss</groupId>
<artifactId>backbase-openapi-tools</artifactId>
<version>0.18.5-SNAPSHOT</version>
<version>0.19.0-SNAPSHOT</version>
</parent>
<artifactId>boat-engine</artifactId>
<packaging>jar</packaging>
Expand Down Expand Up @@ -39,6 +39,12 @@
<artifactId>jakarta.validation-api</artifactId>
</dependency>

<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>

<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
Expand Down Expand Up @@ -86,6 +92,19 @@
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>

Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,87 @@
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;

@Slf4j
@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.
*
* <p>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.
*
* <p>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();
}

}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -60,7 +61,8 @@ public OpenAPI transform(OpenAPI openAPI, Map<String, Object> options) {
}

Map<String, Schema> schemas = openAPI.getComponents().getSchemas();
Map<String, String> renames = findDuplicateRenames(schemas);
ObjectMapper mapper = SerializerUtils.jsonMapper(openAPI);
Map<String, String> renames = findDuplicateRenames(schemas, mapper);

if (renames.isEmpty()) {
log.debug("No duplicate schemas found.");
Expand All @@ -70,7 +72,7 @@ public OpenAPI transform(OpenAPI openAPI, Map<String, Object> 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);
Expand All @@ -82,11 +84,11 @@ public OpenAPI transform(OpenAPI openAPI, Map<String, Object> 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<String, String> findDuplicateRenames(Map<String, Schema> schemas) {
private Map<String, String> findDuplicateRenames(Map<String, Schema> schemas, ObjectMapper mapper) {
Map<JsonNode, List<String>> 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);
});

Expand All @@ -105,14 +107,14 @@ private Map<String, String> findDuplicateRenames(Map<String, Schema> schemas) {
return renames;
}

private void rewriteReferences(OpenAPI openAPI, Map<String, String> renames) {
JsonNode pathsNode = Json.mapper().valueToTree(openAPI.getPaths());
private void rewriteReferences(OpenAPI openAPI, Map<String, String> 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<String, String> renames) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -49,15 +49,18 @@ public class ExtensionFilter implements Transformer {
}

@SneakyThrows
private OpenAPI transform(OpenAPI source, Collection<String> remove) {
final ObjectMapper mapper = Yaml.mapper();
private OpenAPI transform(@NonNull OpenAPI source, Collection<String> 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<String> remove) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"));
}
}
Loading
Loading