From fb0138bf2e6e5d9bf9e30e2c3d3447be2ddf1b87 Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Fri, 7 Aug 2026 14:57:02 +0100 Subject: [PATCH] Fix OpenAPI schemas and request header sizing --- .../adapter/javalin/JavalinHttpServer.java | 32 +++++ .../thingifier/swaggerizer/Swaggerizer.java | 72 +++++++--- .../javalin/JavalinHttpServerTest.java | 99 ++++++++++++-- .../SwaggerizerSchemaExampleTest.java | 123 ++++++++++++++++++ 4 files changed, 300 insertions(+), 26 deletions(-) create mode 100644 thingifier/src/test/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerizerSchemaExampleTest.java diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/javalin/JavalinHttpServer.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/javalin/JavalinHttpServer.java index ee5552f8..ce7d7baa 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/javalin/JavalinHttpServer.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/javalin/JavalinHttpServer.java @@ -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; @@ -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/" }; @@ -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 = "/"; @@ -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; @@ -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(); } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/swaggerizer/Swaggerizer.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/swaggerizer/Swaggerizer.java index 2dbb33b8..9277e9e8 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/swaggerizer/Swaggerizer.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/swaggerizer/Swaggerizer.java @@ -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()); } @@ -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); for (EntityViewDefinition view : objectSchemaDefinition.getViews()) { @@ -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 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); + collectionObject.addRequiredItem(objectSchemaDefinition.getPlural()); + + 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); + } + } + return object; } private static ObjectSchema asObjectSchema(EntityDefinition objectSchemaDefinition) { @@ -664,7 +675,11 @@ private static ObjectSchema asObjectSchema( || propertyDefinition.getType() == FieldType.AUTO_INCREMENT)) { } else { Schema propertyItem = new Schema<>(); - propertyItem.setExample(propertyDefinition.getExamples().get(0)); + final List examples = propertyDefinition.getExamples(); + if (!examples.isEmpty()) { + propertyItem.setExample( + openApiExampleValueFor(propertyDefinition, examples.get(0))); + } List description = new ArrayList<>(); if (propertyDefinition.hasDescription()) { @@ -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(); diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/adapter/javalin/JavalinHttpServerTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/adapter/javalin/JavalinHttpServerTest.java index 320fa404..411033a0 100644 --- a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/adapter/javalin/JavalinHttpServerTest.java +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/adapter/javalin/JavalinHttpServerTest.java @@ -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( @@ -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(); @@ -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); } diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerizerSchemaExampleTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerizerSchemaExampleTest.java new file mode 100644 index 00000000..a027b583 --- /dev/null +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerizerSchemaExampleTest.java @@ -0,0 +1,123 @@ +package uk.co.compendiumdev.thingifier.swaggerizer; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.util.HashSet; +import java.util.Set; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import uk.co.compendiumdev.thingifier.Thingifier; +import uk.co.compendiumdev.thingifier.api.docgen.ThingifierApiDocumentationDefn; +import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.Field; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType; + +class SwaggerizerSchemaExampleTest { + + @Test + void collectionResponseSchemasWrapTheArrayInThePluralNamedObject() { + final Thingifier thingifier = new Thingifier(); + final EntityDefinition item = thingifier.defineThing("item", "items"); + item.addAsPrimaryKeyField(Field.is("id", FieldType.AUTO_INCREMENT).withExample("21")); + item.addField(Field.is("type", FieldType.STRING).withExample("cd")); + item.addField(Field.is("isbn13", FieldType.STRING).withExample("123-4-56-789012-3")); + item.addField(Field.is("price", FieldType.FLOAT).withExample("97.99")); + item.addField(Field.is("numberinstock", FieldType.INTEGER).withExample("0")); + + final ThingifierApiDocumentationDefn apiDefn = + new ThingifierApiDocumentationDefn().setThingifier(thingifier); + + final JsonObject document = + JsonParser.parseString(new Swaggerizer(apiDefn).asJson()).getAsJsonObject(); + final JsonObject itemsSchema = + document.getAsJsonObject("components") + .getAsJsonObject("schemas") + .getAsJsonObject("items"); + final JsonObject itemsProperty = + itemsSchema.getAsJsonObject("properties").getAsJsonObject("items"); + final JsonObject itemSchema = itemsProperty.getAsJsonObject("items"); + + Assertions.assertEquals("object", itemsSchema.get("type").getAsString()); + Assertions.assertEquals("array", itemsProperty.get("type").getAsString()); + Assertions.assertEquals("object", itemSchema.get("type").getAsString()); + Assertions.assertEquals( + "items", itemsSchema.getAsJsonArray("required").get(0).getAsString()); + Assertions.assertEquals( + Set.of("id", "type", "isbn13", "price", "numberinstock"), + stringsIn(itemSchema.getAsJsonArray("required"))); + Assertions.assertEquals( + "#/components/schemas/items", + document.getAsJsonObject("paths") + .getAsJsonObject("/items") + .getAsJsonObject("get") + .getAsJsonObject("responses") + .getAsJsonObject("200") + .getAsJsonObject("content") + .getAsJsonObject("application/json") + .getAsJsonObject("schema") + .get("$ref") + .getAsString()); + } + + private Set stringsIn(final JsonArray values) { + Set strings = new HashSet<>(); + for (JsonElement value : values) { + strings.add(value.getAsString()); + } + return strings; + } + + @Test + void openApiSchemaExamplesUseJsonValuesMatchingTheFieldType() { + final Thingifier thingifier = new Thingifier(); + final EntityDefinition item = thingifier.defineThing("item", "items"); + item.addAsPrimaryKeyField(Field.is("id", FieldType.AUTO_INCREMENT).withExample("21")); + item.addField(Field.is("type", FieldType.STRING).withExample("cd")); + item.addField(Field.is("isbn13", FieldType.STRING).withExample("123-4-56-789012-3")); + item.addField(Field.is("price", FieldType.FLOAT).withExample("97.99")); + item.addField(Field.is("numberinstock", FieldType.INTEGER).withExample("0")); + item.addField(Field.is("active", FieldType.BOOLEAN).withExample("false")); + + final ThingifierApiDocumentationDefn apiDefn = + new ThingifierApiDocumentationDefn().setThingifier(thingifier); + + final JsonObject document = + JsonParser.parseString(new Swaggerizer(apiDefn).asJson()).getAsJsonObject(); + final JsonObject properties = + document.getAsJsonObject("components") + .getAsJsonObject("schemas") + .getAsJsonObject("create_item") + .getAsJsonObject("properties"); + + Assertions.assertTrue( + properties.getAsJsonObject("price").get("example").getAsJsonPrimitive().isNumber()); + Assertions.assertTrue( + properties + .getAsJsonObject("numberinstock") + .get("example") + .getAsJsonPrimitive() + .isNumber()); + Assertions.assertTrue( + properties + .getAsJsonObject("active") + .get("example") + .getAsJsonPrimitive() + .isBoolean()); + Assertions.assertTrue( + properties + .getAsJsonObject("isbn13") + .get("example") + .getAsJsonPrimitive() + .isString()); + + final JsonObject idParameter = + document.getAsJsonObject("paths") + .getAsJsonObject("/items/{id}") + .getAsJsonArray("parameters") + .get(0) + .getAsJsonObject(); + Assertions.assertTrue(idParameter.get("example").getAsJsonPrimitive().isNumber()); + } +}