Skip to content
Merged
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
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ The library uses a flexible JSON mapping system that automatically detects and p
- JSON arrays (`[...]`)
- JSON objects (`{...}`)

## Known limitations (not exhaustive..)
## Local development

- OpenAPI refs are not supported yet.
To test the server in isolation, you can start an example server (`src/test/java/com/retailsvc/http/start/ServerLauncher.java`).
Schemas are located under test resources folder.

- Example requests can be found under `acceptance/k6` that can be a base for exploring the functionality.
- The logger in the configuration needs to be enabled to get some insight into the code.

## Known limitations or missing features
15 changes: 9 additions & 6 deletions acceptance/k6/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export const options = {
],
};

const body = JSON.stringify({
const exampleObjectRequest = {
id: 'some-id',
age: 42,
random: 'd5af5004-8b5a-4db6-838e-38be773eac34',
Expand All @@ -31,12 +31,15 @@ const body = JSON.stringify({
],
aDate: '2025-03-02',
aDateTime: '2025-03-02T12:34:56Z'
});
};

const listOfObjects = JSON.stringify([
const exampleListRequest = [
{ value: 42 },
{ value: 43 }
]);
];

const objectBody = JSON.stringify(exampleObjectRequest);
const listBody = JSON.stringify(exampleListRequest);

export default function () {
group('get request', () => {
Expand All @@ -52,7 +55,7 @@ export default function () {

group('post request', () => {
const url = 'http://localhost:8080/api/v1/data';
const res = http.post(url, body, {
const res = http.post(url, objectBody, {
headers: {
'Content-Type':'application/json',
}
Expand All @@ -67,7 +70,7 @@ export default function () {

group('post list-of-objects request', () => {
const url = 'http://localhost:8080/api/v1/list/objects';
const res = http.post(url, listOfObjects, {
const res = http.post(url, listBody, {
headers: {
'Content-Type':'application/json',
}
Expand Down
1 change: 1 addition & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.16</version>
<scope>provided</scope>
</dependency>

<dependency>
Expand Down
6 changes: 4 additions & 2 deletions src/main/java/com/retailsvc/http/OpenApiServer.java
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ public OpenApiServer(
throws IOException {

long t0 = System.currentTimeMillis();
LOG.debug("Starting server...");

requireNonNull(specification, "OpenAPI specification must not be null");
requireNonNull(jsonMapper, "Request body mapper must not be null");
requireNonNull(requestHandlers, "Request handlers must not be null");
Expand Down Expand Up @@ -95,10 +97,10 @@ private HttpServer initializeServer(
context.setHandler(new RequestDispatchingHandler(specification, requestHandlers));

server.createContext("/", notFoundHandler());

LOG.debug("Starting server...");
server.start();

LOG.info("Server started (port {}) in {}ms", PORT, System.currentTimeMillis() - t0);

return server;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.retailsvc.http.openapi;

import static java.net.HttpURLConnection.HTTP_BAD_REQUEST;
import static java.util.Objects.nonNull;

import com.retailsvc.http.openapi.exceptions.OperationIdNotFoundException;
import com.retailsvc.http.openapi.model.GetRequestBody;
Expand Down Expand Up @@ -36,7 +37,7 @@ public class OpenApiValidationFilter extends Filter implements GetRequestBody {
private final Validator validator;

public OpenApiValidationFilter(OpenApi spec, JsonMapper mapper) {
this(spec, mapper, new ValidatorImpl());
this(spec, mapper, new ValidatorImpl(spec::getResolvedSchema));
}

protected OpenApiValidationFilter(OpenApi spec, JsonMapper mapper, Validator validator) {
Expand Down Expand Up @@ -101,6 +102,10 @@ public void doFilter(HttpExchange exchange, Chain chain) throws IOException {
MediaType mediaType = operation.requestBody().content().get(contentType);
Schema schema = mediaType.schema();

if (nonNull(schema.$ref())) {
schema = specification.getResolvedSchema(schema.$ref());
}

boolean isValid = validator.validate(mappedBody, schema);

LOG.debug("Overall validation is {}", isValid ? "VALID" : "INVALID");
Expand Down
66 changes: 55 additions & 11 deletions src/main/java/com/retailsvc/http/openapi/model/OpenApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand All @@ -23,10 +24,15 @@
* @author thced
*/
public record OpenApi(
String openapi, Info info, Collection<Server> servers, Map<String, PathItem> paths) {
String openapi,
Info info,
Collection<Server> servers,
Map<String, PathItem> paths,
Components components) {

private static final Logger LOG = LoggerFactory.getLogger(OpenApi.class);
private static final Set<String> SUPPORTED_VERSIONS = Set.of("3.1.0");
private static final Map<String, Schema> SCHEMAS_CACHE = new ConcurrentHashMap<>();

public static OpenApi parse(Function<String, OpenApi> fn, String spec) {
return fn.apply(spec);
Expand Down Expand Up @@ -69,6 +75,20 @@ public Optional<Operation> getOperation(String method, String path) {
.findFirst();
}

/**
* Used to get access to the referenced schema components. It will strip off the
* '#/components/schemas/' prefix and cache the found {@link Schema} instance.
*
* @param ref The "full" ref name
* @return The found schema, or null
*/
public Schema getResolvedSchema(String ref) {
String name = ref.replace("#/components/schemas/", "");
Schema found = SCHEMAS_CACHE.computeIfAbsent(name, components::getSchema);
LOG.debug("Found resolved schema: {} -> {}", ref, found);
return found;
}

/**
* The 'info' object.
*
Expand Down Expand Up @@ -155,6 +175,7 @@ public record RequestBody(
public record MediaType(Schema schema) {}

public record Schema(
String $ref,
String type,
String format,
Map<String, Object> properties,
Expand All @@ -163,18 +184,35 @@ public record Schema(
Number maximum,
Number minimum) {

/**
* If Schema has a $ref, we do not set any properties. The properties will be resolved later via
* the referenced component {@link Components}.
*/
public Schema {
if (type == null || type.isBlank()) {
throw new LoadSpecificationException("Type is missing");
}
if (isNull(format) && isNumber()) {
format = "int32";
if (isNull($ref)) {
if (type == null || type.isBlank()) {
throw new LoadSpecificationException("Type is missing");
}
if (isNull(format) && isNumber()) {
format = "int32";
}
required = Objects.requireNonNullElse(required, List.of());
items = Objects.requireNonNullElse(items, Map.of());
properties = Objects.requireNonNullElse(properties, Map.of());
maximum = Objects.requireNonNullElse(maximum, Double.MAX_VALUE);
minimum = Objects.requireNonNullElse(minimum, Double.MIN_VALUE);
}
required = Objects.requireNonNullElse(required, List.of());
items = Objects.requireNonNullElse(items, Map.of());
properties = Objects.requireNonNullElse(properties, Map.of());
maximum = Objects.requireNonNullElse(maximum, Double.MAX_VALUE);
minimum = Objects.requireNonNullElse(minimum, Double.MIN_VALUE);
}

public Schema(
String type,
String format,
Map<String, Object> properties,
Map<String, Object> items,
List<String> required,
Number maximum,
Number minimum) {
this(null, type, format, properties, items, required, maximum, minimum);
}

public boolean isString() {
Expand Down Expand Up @@ -205,4 +243,10 @@ public boolean isArray() {
return "array".equalsIgnoreCase(type);
}
}

public record Components(Map<String, Schema> schemas) {
public Schema getSchema(String name) {
return schemas.get(name);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,25 @@
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class ArrayValidator implements Validator {

private static final Logger LOG = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
private final Validator rootValidator;
private final Function<String, Schema> referencedSchema;

public ArrayValidator(Validator rootValidator) {
/**
* Validate lists
*
* @param rootValidator The parent that delegates types to correct validator
* @param referencedSchema Referenced schema registry
*/
public ArrayValidator(Validator rootValidator, Function<String, Schema> referencedSchema) {
this.rootValidator = rootValidator;
this.referencedSchema = referencedSchema;
}

@Override
Expand All @@ -29,27 +38,28 @@ public boolean validate(Object json, Schema schema) {

Map<String, Object> items = schema.items();
String type = (String) items.get("type");
String $ref = (String) items.get("$ref");
Map<String, Object> props = (Map<String, Object>) items.get("properties");
String format = (String) items.get("format");
List<String> required = (List<String>) items.get("required");
var maximum =
Optional.ofNullable(props)
.map(p -> p.get("maximum"))
.map(Number.class::cast)
.orElse(Double.MAX_VALUE);
var minimum =
Optional.ofNullable(props)
.map(p -> p.get("minimum"))
.map(Number.class::cast)
.orElse(Double.MIN_VALUE);
var max = getLimitForNumber(props, "maximum", Double.MAX_VALUE);
var min = getLimitForNumber(props, "minimum", Double.MIN_VALUE);

for (Object entry : iterable) {
if (!rootValidator.validate(
entry, new Schema(type, format, props, items, required, maximum, minimum))) {
Schema propertySchema =
Optional.ofNullable($ref)
.map(referencedSchema)
.orElseGet(() -> new Schema($ref, type, format, props, items, required, max, min));

if (!rootValidator.validate(entry, propertySchema)) {
LOG.debug("Failed to validate '{}'", entry);
return false;
}
}
return true;
}

private static Number getLimitForNumber(Map<String, Object> props, String name, double limit) {
return Optional.ofNullable(props).map(p -> p.get(name)).map(Number.class::cast).orElse(limit);
}
}
Loading