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
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import org.eclipse.jetty.ee10.servlet.ServletApiRequest;
import org.eclipse.jetty.http.HttpStatus;
import org.eclipse.jetty.io.EndPoint;
import org.eclipse.jetty.server.HttpConfiguration;
import org.eclipse.jetty.util.Blocker;
import uk.co.compendiumdev.thingifier.adapter.httpserver.HaltRequestException;
import uk.co.compendiumdev.thingifier.adapter.httpserver.HttpAfterHandler;
Expand All @@ -28,8 +29,11 @@

public final class JavalinHttpServer implements AutoCloseable {
static final String STATIC_CACHE_CONTROL_PROPERTY = "thingifier.static.cache-control";
static final String REQUEST_HEADER_SIZE_PROPERTY = "thingifier.request-header-size";
private static final String STATIC_CACHE_CONTROL_ENV = "THINGIFIER_STATIC_CACHE_CONTROL";
private static final String REQUEST_HEADER_SIZE_ENV = "THINGIFIER_REQUEST_HEADER_SIZE";
private static final String DEFAULT_STATIC_CACHE_CONTROL = "max-age=0";
private static final int DEFAULT_REQUEST_HEADER_SIZE = 32768;
private static final String[] STATIC_ASSET_PREFIXES = {
"/css/", "/js/", "/favicon/", "/images/"
};
Expand All @@ -52,6 +56,7 @@ public void start() {
Javalin.create(
config -> {
config.router.ignoreTrailingSlashes = false;
config.jetty.modifyHttpConfiguration(JavalinHttpServer::configureHttp);
config.staticFiles.add(
staticFiles -> {
staticFiles.hostedPath = "/";
Expand Down Expand Up @@ -100,6 +105,10 @@ public void start() {
app.start(port);
}

private static void configureHttp(final HttpConfiguration httpConfiguration) {
httpConfiguration.setRequestHeaderSize(requestHeaderSize());
}

private void serveClasspathStaticAsset(final Context ctx) throws Exception {
if (ctx.method() != HandlerType.GET && ctx.method() != HandlerType.HEAD) {
return;
Expand Down Expand Up @@ -161,6 +170,29 @@ static String staticCacheControl() {
return DEFAULT_STATIC_CACHE_CONTROL;
}

static int requestHeaderSize() {
final String configured = System.getProperty(REQUEST_HEADER_SIZE_PROPERTY);
if (hasText(configured)) {
return positiveIntOrDefault(configured, DEFAULT_REQUEST_HEADER_SIZE);
}

final String environment = System.getenv(REQUEST_HEADER_SIZE_ENV);
if (hasText(environment)) {
return positiveIntOrDefault(environment, DEFAULT_REQUEST_HEADER_SIZE);
}

return DEFAULT_REQUEST_HEADER_SIZE;
}

private static int positiveIntOrDefault(final String rawValue, final int defaultValue) {
try {
final int value = Integer.parseInt(rawValue.trim());
return value > 0 ? value : defaultValue;
} catch (NumberFormatException ignored) {
return defaultValue;
}
}

private static boolean hasText(final String value) {
return value != null && !value.trim().isEmpty();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,10 @@ && hasVisibleRouteForUrl(routes, route.url(), config)) {
param.in("path")
.name(urlParameter.name())
.required(true)
.example(aField.getRandomExampleValue());
.example(
openApiExampleValueFor(
aField,
aField.getRandomExampleValue()));
if (aField.hasDescription()) {
param.setDescription(aField.getDescription());
}
Expand Down Expand Up @@ -346,7 +349,7 @@ private Components convertEntityDefinitionsToComponents(
components.addSchemas("create_" + objectSchemaDefinition.getName(), createObject);

// add list response for entity plural
ArraySchema arrayObject = asArrayObjectSchema(objectSchemaDefinition);
ObjectSchema arrayObject = asArrayObjectSchema(objectSchemaDefinition);
components.addSchemas(objectSchemaDefinition.getPlural(), arrayObject);
Comment on lines 351 to 353

for (EntityViewDefinition view : objectSchemaDefinition.getViews()) {
Expand Down Expand Up @@ -596,25 +599,33 @@ private void addHttpSecurityScheme(
components.addSecuritySchemes(name, securityScheme);
}

private ArraySchema asArrayObjectSchema(EntityDefinition objectSchemaDefinition) {
private ObjectSchema asArrayObjectSchema(EntityDefinition objectSchemaDefinition) {

ArraySchema arrayObject = new ArraySchema();
arrayObject.setDescription(objectSchemaDefinition.getPlural());
arrayObject.setTitle(objectSchemaDefinition.getPlural());
// arrayObject.setItems(asObjectSchema(objectSchemaDefinition));

String ref = "#/components/schemas/" + objectSchemaDefinition.getName();

Schema<String> objectRef = new Schema<>();
objectRef.set$ref(ref);
ObjectSchema collectionObject = new ObjectSchema();
collectionObject.setDescription(objectSchemaDefinition.getPlural());
collectionObject.setTitle(objectSchemaDefinition.getPlural());

arrayObject.setItems(objectRef);
ArraySchema arrayObject = new ArraySchema();
arrayObject.setItems(asRequiredResponseObjectSchema(objectSchemaDefinition));

XML xml = new XML();
xml.setWrapped(true);
arrayObject.setXml(xml);
collectionObject.setXml(xml);
collectionObject.addProperties(objectSchemaDefinition.getPlural(), arrayObject);
Comment on lines 611 to +614

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve the XML collection shape

For application/xml collection responses, this object property adds another plural-named layer to the schema: the endpoint emits <items><item>...</item></items>, while the new object schema has an outer collection object containing an items property. Setting wrapped on the outer object does not flatten that property because XML wrapping applies to arrays, and responseContentWith uses this same component for both JSON and XML, so XML client generation and validation no longer match the actual response.

Useful? React with 👍 / 👎.

collectionObject.addRequiredItem(objectSchemaDefinition.getPlural());
Comment on lines 611 to +615

return collectionObject;
}

return arrayObject;
private static ObjectSchema asRequiredResponseObjectSchema(
EntityDefinition objectSchemaDefinition) {
ObjectSchema object = asObjectSchema(objectSchemaDefinition);
if (object.getProperties() != null) {
for (String propertyName : object.getProperties().keySet()) {
object.addRequiredItem(propertyName);
Comment on lines +624 to +625

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep optional response fields optional

When an entity defines an optional DATE or OBJECT field without a default, JsonThing.asFieldJsonObject omits that field because InstanceFields.getFieldValue returns null, but this loop now marks every defined property as required. Collection responses containing such entities therefore fail validation against the generated OpenAPI schema and generated clients assume fields that may be absent; only fields guaranteed to be rendered should be added to required.

Useful? React with 👍 / 👎.

}
}
return object;
}
Comment on lines +620 to 629

private static ObjectSchema asObjectSchema(EntityDefinition objectSchemaDefinition) {
Expand Down Expand Up @@ -664,7 +675,11 @@ private static ObjectSchema asObjectSchema(
|| propertyDefinition.getType() == FieldType.AUTO_INCREMENT)) {
} else {
Schema<String> propertyItem = new Schema<>();
propertyItem.setExample(propertyDefinition.getExamples().get(0));
final List<String> examples = propertyDefinition.getExamples();
if (!examples.isEmpty()) {
propertyItem.setExample(
openApiExampleValueFor(propertyDefinition, examples.get(0)));
}

List<String> description = new ArrayList<>();
if (propertyDefinition.hasDescription()) {
Expand Down Expand Up @@ -712,6 +727,31 @@ private static ObjectSchema asObjectSchema(
return object;
}

private static Object openApiExampleValueFor(final Field field, final String example) {
if (example == null) {
return null;
}

try {
switch (field.getType()) {
case AUTO_INCREMENT:
case INTEGER:
return Integer.valueOf(example);
case FLOAT:
return new BigDecimal(example);
case BOOLEAN:
if ("true".equalsIgnoreCase(example) || "false".equalsIgnoreCase(example)) {
return Boolean.valueOf(example);
}
return example;
default:
return example;
}
} catch (NumberFormatException e) {
return example;
}
}

private static String schemaDescriptionFor(final EntityDefinition objectSchemaDefinition) {
if (objectSchemaDefinition.hasDescription()) {
return objectSchemaDefinition.getDescription();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,43 @@ void classpathStaticAssetsCanUseConfiguredCacheHeader() throws Exception {
}));
}

@Test
void requestHeaderSizeUsesBrowserFriendlyDefault() {
withRequestHeaderSizeProperty(
null, () -> Assertions.assertEquals(32768, JavalinHttpServer.requestHeaderSize()));
}

@Test
void requestHeaderSizeCanUseConfiguredSystemProperty() {
withRequestHeaderSizeProperty(
"49152",
() -> Assertions.assertEquals(49152, JavalinHttpServer.requestHeaderSize()));
}

@Test
void requestHeaderSizeIgnoresInvalidSystemProperty() {
withRequestHeaderSizeProperty(
"not-a-number",
() -> Assertions.assertEquals(32768, JavalinHttpServer.requestHeaderSize()));
}

@Test
void acceptsLargeBrowserCookieHeadersUpToConfiguredRequestHeaderSize() throws Exception {
withStartedServer(
port -> {
String oversizedCookie = "oversized=" + "x".repeat(12000);
String response =
rawHttp(
"GET",
"/css/default.css",
port,
"",
"Cookie: " + oversizedCookie);

Assertions.assertTrue(response.startsWith("HTTP/1.1 200 OK"), response);
});
}

@Test
void emptyNoContentDoesNotReturnContentTypeHeader() throws Exception {
withStartedServer(
Expand Down Expand Up @@ -248,6 +285,33 @@ private void restoreStaticCacheControlProperty(final String originalValue) {
}
}

private void withRequestHeaderSizeProperty(
final String configuredValue, final CheckedRunnable request) {
final String originalValue =
System.getProperty(JavalinHttpServer.REQUEST_HEADER_SIZE_PROPERTY);
if (configuredValue == null) {
System.clearProperty(JavalinHttpServer.REQUEST_HEADER_SIZE_PROPERTY);
} else {
System.setProperty(JavalinHttpServer.REQUEST_HEADER_SIZE_PROPERTY, configuredValue);
}

try {
request.run();
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
restoreRequestHeaderSizeProperty(originalValue);
}
}

private void restoreRequestHeaderSizeProperty(final String originalValue) {
if (originalValue == null) {
System.clearProperty(JavalinHttpServer.REQUEST_HEADER_SIZE_PROPERTY);
} else {
System.setProperty(JavalinHttpServer.REQUEST_HEADER_SIZE_PROPERTY, originalValue);
}
}

private int availablePort() throws Exception {
try (ServerSocket socket = new ServerSocket(0)) {
return socket.getLocalPort();
Expand All @@ -262,20 +326,35 @@ private String rawHttp(final String method, final String path, final int port)
private String rawHttp(
final String method, final String path, final int port, final String body)
throws Exception {
return rawHttp(method, path, port, body, new String[0]);
}

private String rawHttp(
final String method,
final String path,
final int port,
final String body,
final String... headers)
throws Exception {
try (Socket socket = new Socket("localhost", port)) {
socket.setSoTimeout(5000);
byte[] bodyBytes = body.getBytes(StandardCharsets.ISO_8859_1);
StringBuilder rawRequest = new StringBuilder();
rawRequest
.append(method)
.append(" ")
.append(path)
.append(" HTTP/1.1\r\nHost: localhost:")
.append(port)
.append("\r\nContent-Length: ")
.append(bodyBytes.length)
.append("\r\n");
for (String header : headers) {
rawRequest.append(header).append("\r\n");
}
rawRequest.append("Connection: close\r\n\r\n");
socket.getOutputStream()
.write(
(method
+ " "
+ path
+ " HTTP/1.1\r\nHost: localhost:"
+ port
+ "\r\nContent-Length: "
+ bodyBytes.length
+ "\r\nConnection: close\r\n\r\n")
.getBytes(StandardCharsets.ISO_8859_1));
.write(rawRequest.toString().getBytes(StandardCharsets.ISO_8859_1));
socket.getOutputStream().write(bodyBytes);
return new String(socket.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
}
Expand Down
Loading
Loading