Skip to content

[WIP] Add support for custom media types #101

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
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
97 changes: 31 additions & 66 deletions src/main/java/io/vertx/openapi/contract/OpenAPIContract.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,32 +12,27 @@

package io.vertx.openapi.contract;

import io.vertx.codegen.annotations.GenIgnore;
import io.vertx.codegen.annotations.Nullable;
import io.vertx.codegen.annotations.VertxGen;
import io.vertx.core.Future;
import io.vertx.core.Promise;
import io.vertx.core.Vertx;
import io.vertx.core.http.HttpMethod;
import io.vertx.core.internal.ContextInternal;
import io.vertx.core.json.JsonObject;
import io.vertx.json.schema.JsonSchema;
import io.vertx.json.schema.JsonSchemaValidationException;
import io.vertx.json.schema.SchemaRepository;
import io.vertx.openapi.contract.impl.OpenAPIContractImpl;
import io.vertx.openapi.mediatype.MediaTypeRegistry;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import static io.vertx.core.Future.failedFuture;
import static io.vertx.openapi.contract.OpenAPIContractException.createInvalidContract;
import static io.vertx.openapi.impl.Utils.readYamlOrJson;
import static java.util.Collections.emptyMap;

@VertxGen
public interface OpenAPIContract {


static OpenAPIContractBuilder builder(Vertx vertx) {
return new OpenAPIContractBuilder(vertx);
}

/**
* Resolves / dereferences the passed contract and creates an {@link OpenAPIContract} instance.
*
Expand All @@ -46,7 +41,7 @@ public interface OpenAPIContract {
* @return A succeeded {@link Future} holding an {@link OpenAPIContract} instance, otherwise a failed {@link Future}.
*/
static Future<OpenAPIContract> from(Vertx vertx, String unresolvedContractPath) {
return readYamlOrJson(vertx, unresolvedContractPath).compose(json -> from(vertx, json));
return builder(vertx).contract(unresolvedContractPath).build();
}

/**
Expand All @@ -57,7 +52,11 @@ static Future<OpenAPIContract> from(Vertx vertx, String unresolvedContractPath)
* @return A succeeded {@link Future} holding an {@link OpenAPIContract} instance, otherwise a failed {@link Future}.
*/
static Future<OpenAPIContract> from(Vertx vertx, JsonObject unresolvedContract) {
return from(vertx, unresolvedContract, emptyMap());
if (unresolvedContract == null)
return Future.failedFuture(OpenAPIContractException.createInvalidContract("Spec must not be null"));
return builder(vertx)
.contract(unresolvedContract)
.build();
}

/**
Expand All @@ -74,15 +73,10 @@ static Future<OpenAPIContract> from(Vertx vertx, JsonObject unresolvedContract)
static Future<OpenAPIContract> from(Vertx vertx, String unresolvedContractPath,
Map<String, String> additionalContractFiles) {

Map<String, Future<JsonObject>> jsonFilesFuture = new HashMap<>();
jsonFilesFuture.put(unresolvedContractPath, readYamlOrJson(vertx, unresolvedContractPath));
additionalContractFiles.forEach((key, value) -> jsonFilesFuture.put(key, readYamlOrJson(vertx, value)));

return Future.all(new ArrayList<>(jsonFilesFuture.values())).compose(compFut -> {
Map<String, JsonObject> resolvedFiles = new HashMap<>();
additionalContractFiles.keySet().forEach(key -> resolvedFiles.put(key, jsonFilesFuture.get(key).result()));
return from(vertx, jsonFilesFuture.get(unresolvedContractPath).result(), resolvedFiles);
});
return builder(vertx)
.contract(unresolvedContractPath)
.addAdditionalContentFiles(additionalContractFiles)
.build();
}

/**
Expand All @@ -98,49 +92,12 @@ static Future<OpenAPIContract> from(Vertx vertx, String unresolvedContractPath,
*/
static Future<OpenAPIContract> from(Vertx vertx, JsonObject unresolvedContract,
Map<String, JsonObject> additionalContractFiles) {
if (unresolvedContract == null) {
return failedFuture(createInvalidContract("Spec must not be null"));
}

OpenAPIVersion version = OpenAPIVersion.fromContract(unresolvedContract);
String baseUri = "app://";

ContextInternal ctx = (ContextInternal) vertx.getOrCreateContext();
Promise<OpenAPIContract> promise = ctx.promise();

version.getRepository(vertx, baseUri)
.compose(repository -> {
List<Future<?>> validationFutures = new ArrayList<>(additionalContractFiles.size());
for (String ref : additionalContractFiles.keySet()) {
// Todo: As soon a more modern Java version is used the validate part could be extracted in a private static
// method and reused below.
JsonObject file = additionalContractFiles.get(ref);
Future<?> validationFuture = version.validateAdditionalContractFile(vertx, repository, file)
.compose(v -> vertx.executeBlocking(() -> repository.dereference(ref, JsonSchema.of(ref, file))));

validationFutures.add(validationFuture);
}
return Future.all(validationFutures).map(repository);
}).compose(repository ->
version.validateContract(vertx, repository, unresolvedContract).compose(res -> {
try {
res.checkValidity();
return version.resolve(vertx, repository, unresolvedContract);
} catch (JsonSchemaValidationException | UnsupportedOperationException e) {
return failedFuture(createInvalidContract(null, e));
}
})
.map(resolvedSpec -> (OpenAPIContract) new OpenAPIContractImpl(resolvedSpec, version, repository))
).recover(e -> {
//Convert any non-openapi exceptions into an OpenAPIContractException
if(e instanceof OpenAPIContractException) {
return failedFuture(e);
}

return failedFuture(createInvalidContract("Found issue in specification for reference: " + e.getMessage(), e));
}).onComplete(promise);

return promise.future();
if (unresolvedContract == null)
return Future.failedFuture(OpenAPIContractException.createInvalidContract("Spec must not be null"));
return builder(vertx)
.contract(unresolvedContract)
.addAdditionalContent(additionalContractFiles)
.build();
}

/**
Expand Down Expand Up @@ -219,4 +176,12 @@ static Future<OpenAPIContract> from(Vertx vertx, JsonObject unresolvedContract,
*/
@Nullable
SecurityScheme securityScheme(String name);

/**
* Gets the mediatype registry.
*
* @return The registry.
*/
@GenIgnore
MediaTypeRegistry mediaTypes();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
MediaTypeRegistry mediaTypes();
MediaTypeRegistry getMediaTypeRegistry();

}
216 changes: 216 additions & 0 deletions src/main/java/io/vertx/openapi/contract/OpenAPIContractBuilder.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
package io.vertx.openapi.contract;

import io.vertx.codegen.annotations.GenIgnore;
import io.vertx.core.Future;
import io.vertx.core.Promise;
import io.vertx.core.Vertx;
import io.vertx.core.internal.ContextInternal;
import io.vertx.core.json.JsonObject;
import io.vertx.json.schema.JsonSchema;
import io.vertx.json.schema.JsonSchemaValidationException;
import io.vertx.openapi.contract.impl.OpenAPIContractImpl;
import io.vertx.openapi.impl.Utils;
import io.vertx.openapi.mediatype.MediaTypeRegistry;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import static io.vertx.core.Future.failedFuture;
import static io.vertx.openapi.contract.OpenAPIContractException.createInvalidContract;

@GenIgnore
public class OpenAPIContractBuilder {

public static class OpenAPIContractBuilderException extends RuntimeException {
public OpenAPIContractBuilderException(String message) {
super(message);
}
}

private final Vertx vertx;
private String contractFile;
private JsonObject contract;
private final Map<String, String> additionalContentFiles = new HashMap<>();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

additionalContractFiles

private final Map<String, JsonObject> additionalContent = new HashMap<>();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

additionalContracts

private MediaTypeRegistry registry;

public OpenAPIContractBuilder(Vertx vertx) {
this.vertx = vertx;
}

/**
* Sets the path to the contract file. Either provide the path to the contract or the parsed contract,
* not both {@link #contract(JsonObject)}.
*
* @param contractPath The path to the contract file
* @return The builder, for a fluent interface
*/
public OpenAPIContractBuilder contract(String contractPath) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setContract and you can get rid of the exception

if (this.contract != null)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isNullOrEmpty?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check and exception won't be required anymore when each method-call of the setContract-Method replaces the existing values for file and content

throw new OpenAPIContractBuilderException("A parsed contract was already set. Only set a parsed contract or a path to a contract file, not both.");
this.contractFile = contractPath;
return this;
}

/**
* Sets the contract. Either provide the contract or the path to the contract,
* not both {@link #contract(String)}.
*
* @param contract The parsed contract
* @return The builder, for a fluent interface
*/
public OpenAPIContractBuilder contract(JsonObject contract) {
if (this.contractFile != null)
throw new OpenAPIContractBuilderException("A contract file was already set. Only set a parsed contract or a path to a contract file, not both.");
this.contract = contract;
return this;
}

/**
* Adds an additional contract that is referenced by the main contract. This method can be
* called multiple times to add multiple referenced contracts.
*
* @param key The unique key for the contract.
* @param path The path to the contract file.
* @return The builder, for a fluent interface
*/
public OpenAPIContractBuilder addAdditionalContent(String key, String path) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

putAdditionalContract <- then you don't have to check for duplicates. It also allows you to override a single contract which was maybe added by "addAdditionalContentFiles".

checkDuplicateKeys(key);
additionalContentFiles.put(key, path);
return this;
}

public OpenAPIContractBuilder addAdditionalContentFiles(Map<String, String> otherContractFiles) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setAdditionalContractFiles <- then you don't have to check for duplicates. And you are able to remove something. This would require to reset the other Map of course.

for (var e : otherContractFiles.entrySet()) {
addAdditionalContent(e.getKey(), e.getValue());
}
return this;
}


/**
* Adds an additional contract that is referenced by the main contract. This method can be
* called multiple times to add multiple referenced contracts.
*
* @param key The unique key for the contract.
* @param content The parsed contract.
* @return The builder, for a fluent interface
*/
public OpenAPIContractBuilder addAdditionalContent(String key, JsonObject content) {
checkDuplicateKeys(key);
additionalContent.put(key, content);
return this;
}

public OpenAPIContractBuilder addAdditionalContent(Map<String, JsonObject> otherContracts) {
for (var e : otherContracts.entrySet()) {
addAdditionalContent(e.getKey(), e.getValue());
}
return this;
}

public OpenAPIContractBuilder mediaTypeRegistry(MediaTypeRegistry registry) {
this.registry = registry;
return this;
}

private void checkDuplicateKeys(String key) {
if (additionalContentFiles.containsKey(key) || additionalContent.containsKey(key)) {
throw new OpenAPIContractBuilderException(String.format("The key '%s' has been added twice.", key));
}
}

/**
* Builds the contract.
*
* @return The contract.
*/
public Future<OpenAPIContract> build() {
if (contractFile == null && contract == null) {
return Future.failedFuture(new OpenAPIContractBuilderException("Neither a contract file or a contract is set. One of them must be set."));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line clashes with

return Future.failedFuture(OpenAPIContractException.createInvalidContract("Spec must not be null"));

from OpenAPIContract

Same issue, but different Exceptions. Maybe this Exception makes more sense in the Builder. It would be a breaking change, but it is okay as Vert.x OpenAPI is in Technical Preview. Let's discuss it more deeply.

}
if (this.registry == null) this.registry = MediaTypeRegistry.createDefault();

Future<JsonObject> readContract = contractFile == null
? Future.succeededFuture(contract)
: Utils.readYamlOrJson(vertx, contractFile);

var resolvedContracts = Future
.succeededFuture(additionalContent)
.compose(x -> readContractFiles()
.map(r -> {
var all = new HashMap<>(x);
all.putAll(r);
return all;
}));

return Future.all(readContract, resolvedContracts)
.compose(x -> {
JsonObject contract = x.resultAt(0);
Map<String, JsonObject> other = x.resultAt(1);
return from(contract, other);
});
}

private Future<OpenAPIContract> from(JsonObject unresolvedContract,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my opinion from doesn't make sense in the scope of Builder. This is the real "build" method. The content of the current "build" method is more or less a consolidation or pre-work / init for the real "build"

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this method could be stay in OpenAPIContract and the build method in Builder is calling it. This would also solve the issue with different Exceptions, because then the Builder really just aggregates settings for the Contract and has no own logic.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method will probably change each time a new builder parameter is introduced. I am not sure if this will improve the code if the creation of an object is split accross two different public classes. I may see it located in the OpenAPIContractImpl class, as this is the actual class that is effected by it.

Additionally I'd like to see the builder as the primary way to initialize Contracts and the existing factory methods as the second choice or even as a deprecated choice once the builder exists.

Map<String, JsonObject> additionalContractFiles) {
if (unresolvedContract == null) {
return failedFuture(createInvalidContract("Spec must not be null"));
}

OpenAPIVersion version = OpenAPIVersion.fromContract(unresolvedContract);
String baseUri = "app://";

ContextInternal ctx = (ContextInternal) vertx.getOrCreateContext();
Promise<OpenAPIContract> promise = ctx.promise();

version.getRepository(vertx, baseUri)
.compose(repository -> {
List<Future<?>> validationFutures = new ArrayList<>(additionalContractFiles.size());
for (String ref : additionalContractFiles.keySet()) {
// Todo: As soon a more modern Java version is used the validate part could be extracted in a private static
// method and reused below.
JsonObject file = additionalContractFiles.get(ref);
Future<?> validationFuture = version.validateAdditionalContractFile(vertx, repository, file)
.compose(v -> vertx.executeBlocking(() -> repository.dereference(ref, JsonSchema.of(ref, file))));

validationFutures.add(validationFuture);
}
return Future.all(validationFutures).map(repository);
}).compose(repository ->
version.validateContract(vertx, repository, unresolvedContract).compose(res -> {
try {
res.checkValidity();
return version.resolve(vertx, repository, unresolvedContract);
} catch (JsonSchemaValidationException | UnsupportedOperationException e) {
return failedFuture(createInvalidContract(null, e));
}
})
.map(resolvedSpec -> new OpenAPIContractImpl(resolvedSpec, version, repository, registry))
).recover(e -> {
//Convert any non-openapi exceptions into an OpenAPIContractException
if (e instanceof OpenAPIContractException) {
return failedFuture(e);
}

return failedFuture(createInvalidContract("Found issue in specification for reference: " + e.getMessage(), e));
}).onComplete(promise);

return promise.future();
}

private Future<Map<String, JsonObject>> readContractFiles() {
if (additionalContentFiles.isEmpty()) return Future.succeededFuture(Map.of());

var read = new HashMap<String, JsonObject>();
return Future.all(additionalContentFiles.entrySet().stream()
.map(e -> Utils.readYamlOrJson(vertx, e.getValue())
.map(c -> read.put(e.getKey(), c)))
.collect(Collectors.toList()))
.map(ign -> read);
}

}
Loading