Skip to content

Commit 5686657

Browse files
authored
feat: Add support for refs in request bodies (#7)
1 parent 9660110 commit 5686657

15 files changed

Lines changed: 332 additions & 178 deletions

File tree

README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,12 @@ The library uses a flexible JSON mapping system that automatically detects and p
104104
- JSON arrays (`[...]`)
105105
- JSON objects (`{...}`)
106106

107-
## Known limitations (not exhaustive..)
107+
## Local development
108108

109-
- OpenAPI refs are not supported yet.
109+
To test the server in isolation, you can start an example server (`src/test/java/com/retailsvc/http/start/ServerLauncher.java`).
110+
Schemas are located under test resources folder.
111+
112+
- Example requests can be found under `acceptance/k6` that can be a base for exploring the functionality.
113+
- The logger in the configuration needs to be enabled to get some insight into the code.
114+
115+
## Known limitations or missing features

acceptance/k6/script.js

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export const options = {
1010
],
1111
};
1212

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

36-
const listOfObjects = JSON.stringify([
36+
const exampleListRequest = [
3737
{ value: 42 },
3838
{ value: 43 }
39-
]);
39+
];
40+
41+
const objectBody = JSON.stringify(exampleObjectRequest);
42+
const listBody = JSON.stringify(exampleListRequest);
4043

4144
export default function () {
4245
group('get request', () => {
@@ -52,7 +55,7 @@ export default function () {
5255

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

6871
group('post list-of-objects request', () => {
6972
const url = 'http://localhost:8080/api/v1/list/objects';
70-
const res = http.post(url, listOfObjects, {
73+
const res = http.post(url, listBody, {
7174
headers: {
7275
'Content-Type':'application/json',
7376
}

pom.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
<groupId>org.slf4j</groupId>
4444
<artifactId>slf4j-api</artifactId>
4545
<version>2.0.16</version>
46+
<scope>provided</scope>
4647
</dependency>
4748

4849
<dependency>

src/main/java/com/retailsvc/http/OpenApiServer.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ public OpenApiServer(
6565
throws IOException {
6666

6767
long t0 = System.currentTimeMillis();
68+
LOG.debug("Starting server...");
69+
6870
requireNonNull(specification, "OpenAPI specification must not be null");
6971
requireNonNull(jsonMapper, "Request body mapper must not be null");
7072
requireNonNull(requestHandlers, "Request handlers must not be null");
@@ -95,10 +97,10 @@ private HttpServer initializeServer(
9597
context.setHandler(new RequestDispatchingHandler(specification, requestHandlers));
9698

9799
server.createContext("/", notFoundHandler());
98-
99-
LOG.debug("Starting server...");
100100
server.start();
101+
101102
LOG.info("Server started (port {}) in {}ms", PORT, System.currentTimeMillis() - t0);
103+
102104
return server;
103105
}
104106

src/main/java/com/retailsvc/http/openapi/OpenApiValidationFilter.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.retailsvc.http.openapi;
22

33
import static java.net.HttpURLConnection.HTTP_BAD_REQUEST;
4+
import static java.util.Objects.nonNull;
45

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

3839
public OpenApiValidationFilter(OpenApi spec, JsonMapper mapper) {
39-
this(spec, mapper, new ValidatorImpl());
40+
this(spec, mapper, new ValidatorImpl(spec::getResolvedSchema));
4041
}
4142

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

105+
if (nonNull(schema.$ref())) {
106+
schema = specification.getResolvedSchema(schema.$ref());
107+
}
108+
104109
boolean isValid = validator.validate(mappedBody, schema);
105110

106111
LOG.debug("Overall validation is {}", isValid ? "VALID" : "INVALID");

src/main/java/com/retailsvc/http/openapi/model/OpenApi.java

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import java.util.Objects;
1414
import java.util.Optional;
1515
import java.util.Set;
16+
import java.util.concurrent.ConcurrentHashMap;
1617
import java.util.function.Function;
1718
import org.slf4j.Logger;
1819
import org.slf4j.LoggerFactory;
@@ -23,10 +24,15 @@
2324
* @author thced
2425
*/
2526
public record OpenApi(
26-
String openapi, Info info, Collection<Server> servers, Map<String, PathItem> paths) {
27+
String openapi,
28+
Info info,
29+
Collection<Server> servers,
30+
Map<String, PathItem> paths,
31+
Components components) {
2732

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

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

78+
/**
79+
* Used to get access to the referenced schema components. It will strip off the
80+
* '#/components/schemas/' prefix and cache the found {@link Schema} instance.
81+
*
82+
* @param ref The "full" ref name
83+
* @return The found schema, or null
84+
*/
85+
public Schema getResolvedSchema(String ref) {
86+
String name = ref.replace("#/components/schemas/", "");
87+
Schema found = SCHEMAS_CACHE.computeIfAbsent(name, components::getSchema);
88+
LOG.debug("Found resolved schema: {} -> {}", ref, found);
89+
return found;
90+
}
91+
7292
/**
7393
* The 'info' object.
7494
*
@@ -155,6 +175,7 @@ public record RequestBody(
155175
public record MediaType(Schema schema) {}
156176

157177
public record Schema(
178+
String $ref,
158179
String type,
159180
String format,
160181
Map<String, Object> properties,
@@ -163,18 +184,35 @@ public record Schema(
163184
Number maximum,
164185
Number minimum) {
165186

187+
/**
188+
* If Schema has a $ref, we do not set any properties. The properties will be resolved later via
189+
* the referenced component {@link Components}.
190+
*/
166191
public Schema {
167-
if (type == null || type.isBlank()) {
168-
throw new LoadSpecificationException("Type is missing");
169-
}
170-
if (isNull(format) && isNumber()) {
171-
format = "int32";
192+
if (isNull($ref)) {
193+
if (type == null || type.isBlank()) {
194+
throw new LoadSpecificationException("Type is missing");
195+
}
196+
if (isNull(format) && isNumber()) {
197+
format = "int32";
198+
}
199+
required = Objects.requireNonNullElse(required, List.of());
200+
items = Objects.requireNonNullElse(items, Map.of());
201+
properties = Objects.requireNonNullElse(properties, Map.of());
202+
maximum = Objects.requireNonNullElse(maximum, Double.MAX_VALUE);
203+
minimum = Objects.requireNonNullElse(minimum, Double.MIN_VALUE);
172204
}
173-
required = Objects.requireNonNullElse(required, List.of());
174-
items = Objects.requireNonNullElse(items, Map.of());
175-
properties = Objects.requireNonNullElse(properties, Map.of());
176-
maximum = Objects.requireNonNullElse(maximum, Double.MAX_VALUE);
177-
minimum = Objects.requireNonNullElse(minimum, Double.MIN_VALUE);
205+
}
206+
207+
public Schema(
208+
String type,
209+
String format,
210+
Map<String, Object> properties,
211+
Map<String, Object> items,
212+
List<String> required,
213+
Number maximum,
214+
Number minimum) {
215+
this(null, type, format, properties, items, required, maximum, minimum);
178216
}
179217

180218
public boolean isString() {
@@ -205,4 +243,10 @@ public boolean isArray() {
205243
return "array".equalsIgnoreCase(type);
206244
}
207245
}
246+
247+
public record Components(Map<String, Schema> schemas) {
248+
public Schema getSchema(String name) {
249+
return schemas.get(name);
250+
}
251+
}
208252
}

src/main/java/com/retailsvc/http/openapi/validation/ArrayValidator.java

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,25 @@
55
import java.util.List;
66
import java.util.Map;
77
import java.util.Optional;
8+
import java.util.function.Function;
89
import org.slf4j.Logger;
910
import org.slf4j.LoggerFactory;
1011

1112
public class ArrayValidator implements Validator {
1213

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

16-
public ArrayValidator(Validator rootValidator) {
18+
/**
19+
* Validate lists
20+
*
21+
* @param rootValidator The parent that delegates types to correct validator
22+
* @param referencedSchema Referenced schema registry
23+
*/
24+
public ArrayValidator(Validator rootValidator, Function<String, Schema> referencedSchema) {
1725
this.rootValidator = rootValidator;
26+
this.referencedSchema = referencedSchema;
1827
}
1928

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

3039
Map<String, Object> items = schema.items();
3140
String type = (String) items.get("type");
41+
String $ref = (String) items.get("$ref");
3242
Map<String, Object> props = (Map<String, Object>) items.get("properties");
3343
String format = (String) items.get("format");
3444
List<String> required = (List<String>) items.get("required");
35-
var maximum =
36-
Optional.ofNullable(props)
37-
.map(p -> p.get("maximum"))
38-
.map(Number.class::cast)
39-
.orElse(Double.MAX_VALUE);
40-
var minimum =
41-
Optional.ofNullable(props)
42-
.map(p -> p.get("minimum"))
43-
.map(Number.class::cast)
44-
.orElse(Double.MIN_VALUE);
45+
var max = getLimitForNumber(props, "maximum", Double.MAX_VALUE);
46+
var min = getLimitForNumber(props, "minimum", Double.MIN_VALUE);
4547

4648
for (Object entry : iterable) {
47-
if (!rootValidator.validate(
48-
entry, new Schema(type, format, props, items, required, maximum, minimum))) {
49+
Schema propertySchema =
50+
Optional.ofNullable($ref)
51+
.map(referencedSchema)
52+
.orElseGet(() -> new Schema($ref, type, format, props, items, required, max, min));
53+
54+
if (!rootValidator.validate(entry, propertySchema)) {
4955
LOG.debug("Failed to validate '{}'", entry);
5056
return false;
5157
}
5258
}
5359
return true;
5460
}
61+
62+
private static Number getLimitForNumber(Map<String, Object> props, String name, double limit) {
63+
return Optional.ofNullable(props).map(p -> p.get(name)).map(Number.class::cast).orElse(limit);
64+
}
5565
}

0 commit comments

Comments
 (0)