diff --git a/README.md b/README.md index cc0fa8e..86a765a 100644 --- a/README.md +++ b/README.md @@ -776,7 +776,7 @@ var server = OpenApiServer.builder() .handlers(handlers) .extraRoute("/alive", Handlers.aliveHandler()) .extraRoute("/schemas/v1/openapi.yaml", - Handlers.specHandler("/schemas/v1/openapi.yaml")) + Handlers.resourceHandler("/schemas/v1/openapi.yaml")) .build(); ``` @@ -787,8 +787,10 @@ routes. Built-in helpers: - `Handlers.aliveHandler()` — 204 No Content on `GET`/`HEAD`, 405 otherwise. -- `Handlers.specHandler(classpathResource)` — serves a classpath resource (content-type inferred - from extension). Throws `IllegalArgumentException` at construction if the resource is missing. +- `Handlers.resourceHandler(classpathResource)` / `Handlers.resourceHandler(Path)` — streams a + classpath resource or filesystem file (content-type inferred from extension; the stream is + opened and closed per request, and the handler owns its lifecycle). Throws + `IllegalArgumentException` at construction if the resource or file is missing. ## End-to-end example diff --git a/src/main/java/com/retailsvc/http/Handlers.java b/src/main/java/com/retailsvc/http/Handlers.java index 1ab6c12..8ca9abd 100644 --- a/src/main/java/com/retailsvc/http/Handlers.java +++ b/src/main/java/com/retailsvc/http/Handlers.java @@ -9,9 +9,11 @@ import static java.net.HttpURLConnection.HTTP_UNAVAILABLE; import static java.nio.charset.StandardCharsets.UTF_8; -import com.retailsvc.http.internal.ClasspathResourceHandler; import com.retailsvc.http.internal.HealthRenderer; import com.retailsvc.http.internal.ProblemDetail; +import com.retailsvc.http.internal.ResourceSource; +import java.io.InputStream; +import java.nio.file.Path; import java.util.List; import java.util.Objects; import java.util.function.Supplier; @@ -102,22 +104,45 @@ public static RequestHandler healthHandler(Supplier probe) { } /** - * Serves a classpath resource. Content-Type is inferred from the file extension. The resource is - * loaded eagerly; a missing resource fails immediately with {@link IllegalArgumentException}. + * Serves a classpath resource as a streaming response. Content-Type is inferred from the file + * extension. Existence and length are resolved at construction; a missing resource fails + * immediately with {@link IllegalArgumentException}. The resource is opened and closed per + * request — the handler owns the stream lifecycle. * * @param classpathResource absolute classpath path, e.g. {@code /schemas/v1/openapi.yaml} */ - public static RequestHandler specHandler(String classpathResource) { - ClasspathResourceHandler resource = new ClasspathResourceHandler(classpathResource); - byte[] bytes = resource.bytes(); - String contentType = resource.contentType(); + public static RequestHandler resourceHandler(String classpathResource) { + return resourceHandler(ResourceSource.ofClasspath(classpathResource)); + } + + /** + * Serves a filesystem file as a streaming response. Content-Type is inferred from the file + * extension. Existence and length are resolved at construction; a missing file fails immediately + * with {@link IllegalArgumentException}. The file is opened and closed per request. + */ + public static RequestHandler resourceHandler(Path file) { + return resourceHandler(ResourceSource.ofFile(file)); + } + + private static RequestHandler resourceHandler(ResourceSource source) { + long length = source.length(); + String contentType = source.contentType(); return req -> switch (req.method()) { - case GET -> Response.bytes(HTTP_OK, bytes, contentType); + case GET -> + Response.stream( + HTTP_OK, + length, + contentType, + out -> { + try (InputStream in = source.open()) { + in.transferTo(out); + } + }); case HEAD -> Response.status(HTTP_OK) .withContentType(contentType) - .withHeader("Content-Length", String.valueOf(bytes.length)); + .withHeader("Content-Length", String.valueOf(length)); default -> Response.status(HTTP_BAD_METHOD).withHeader("Allow", "GET, HEAD"); }; } diff --git a/src/main/java/com/retailsvc/http/internal/ClasspathResourceHandler.java b/src/main/java/com/retailsvc/http/internal/ClasspathResourceHandler.java deleted file mode 100644 index 64e9417..0000000 --- a/src/main/java/com/retailsvc/http/internal/ClasspathResourceHandler.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.retailsvc.http.internal; - -import java.io.IOException; -import java.io.InputStream; -import java.util.Locale; - -/** - * Eagerly-loaded bytes for a classpath resource. Content-Type is inferred from the file extension. - * Throws {@link IllegalArgumentException} if the resource is missing. - */ -public final class ClasspathResourceHandler { - - private final byte[] bytes; - private final String contentType; - - public ClasspathResourceHandler(String classpathResource) { - try (InputStream in = ClasspathResourceHandler.class.getResourceAsStream(classpathResource)) { - if (in == null) { - throw new IllegalArgumentException("classpath resource not found: " + classpathResource); - } - this.bytes = in.readAllBytes(); - } catch (IOException io) { - throw new IllegalArgumentException( - "failed reading classpath resource: " + classpathResource, io); - } - this.contentType = contentTypeFor(classpathResource); - } - - public byte[] bytes() { - return bytes; - } - - public String contentType() { - return contentType; - } - - private static String contentTypeFor(String path) { - String lower = path.toLowerCase(Locale.ROOT); - if (lower.endsWith(".json")) { - return "application/json"; - } - if (lower.endsWith(".yaml") || lower.endsWith(".yml")) { - return "application/yaml"; - } - if (lower.endsWith(".txt")) { - return "text/plain; charset=utf-8"; - } - return "application/octet-stream"; - } -} diff --git a/src/main/java/com/retailsvc/http/internal/ResourceSource.java b/src/main/java/com/retailsvc/http/internal/ResourceSource.java new file mode 100644 index 0000000..3e06f23 --- /dev/null +++ b/src/main/java/com/retailsvc/http/internal/ResourceSource.java @@ -0,0 +1,105 @@ +package com.retailsvc.http.internal; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; +import java.util.Objects; + +/** + * A streamable, fail-fast handle to a resource on the classpath or filesystem. Existence and length + * are resolved at construction; the underlying bytes are not buffered. Each {@link #open()} call + * returns a fresh {@link InputStream} that the caller must close. + */ +public sealed interface ResourceSource { + + long length(); + + String contentType(); + + String describe(); + + InputStream open() throws IOException; + + static ResourceSource ofClasspath(String classpathResource) { + Objects.requireNonNull(classpathResource, "classpathResource"); + long length; + try (InputStream in = ResourceSource.class.getResourceAsStream(classpathResource)) { + if (in == null) { + throw new IllegalArgumentException("classpath resource not found: " + classpathResource); + } + length = in.transferTo(OutputStream.nullOutputStream()); + } catch (IOException io) { + throw new IllegalArgumentException( + "failed reading classpath resource: " + classpathResource, io); + } + return new Classpath(classpathResource, length, contentTypeFor(classpathResource)); + } + + static ResourceSource ofFile(Path file) { + Objects.requireNonNull(file, "file"); + if (!Files.isRegularFile(file)) { + throw new IllegalArgumentException("file not found or not a regular file: " + file); + } + long length; + try { + length = Files.size(file); + } catch (IOException io) { + throw new IllegalArgumentException("failed reading file: " + file, io); + } + return new File(file, length, contentTypeFor(file.getFileName().toString())); + } + + static String contentTypeFor(String path) { + String lower = path.toLowerCase(Locale.ROOT); + if (lower.endsWith(".json")) { + return "application/json"; + } + if (lower.endsWith(".yaml") || lower.endsWith(".yml")) { + return "application/yaml"; + } + if (lower.endsWith(".html") || lower.endsWith(".htm")) { + return "text/html; charset=utf-8"; + } + if (lower.endsWith(".css")) { + return "text/css; charset=utf-8"; + } + if (lower.endsWith(".js")) { + return "text/javascript; charset=utf-8"; + } + if (lower.endsWith(".txt")) { + return "text/plain; charset=utf-8"; + } + return "application/octet-stream"; + } + + record Classpath(String path, long length, String contentType) implements ResourceSource { + @Override + public InputStream open() throws IOException { + InputStream in = ResourceSource.class.getResourceAsStream(path); + if (in == null) { + throw new IOException("classpath resource disappeared: " + path); + } + return in; + } + + @Override + public String describe() { + return "classpath:" + path; + } + } + + record File(Path path, long length, String contentType) implements ResourceSource { + @Override + public InputStream open() throws IOException { + return Files.newInputStream(path); + } + + @Override + public String describe() { + return path.toString(); + } + } +} diff --git a/src/test/java/com/retailsvc/http/ExtraHandlersIT.java b/src/test/java/com/retailsvc/http/ExtraHandlersIT.java index f5e4dfa..4eea867 100644 --- a/src/test/java/com/retailsvc/http/ExtraHandlersIT.java +++ b/src/test/java/com/retailsvc/http/ExtraHandlersIT.java @@ -34,13 +34,13 @@ void aliveExtraReturns204AndBypassesValidation() throws Exception { } @Test - void specHandlerServesClasspathResource() throws Exception { + void resourceHandlerServesClasspathResource() throws Exception { try (var s = newBuilder() .spec(spec) .handlers(Map.of()) .port(0) - .extraRoute("/openapi.yaml", Handlers.specHandler("/openapi.yaml")) + .extraRoute("/openapi.yaml", Handlers.resourceHandler("/openapi.yaml")) .build(); var client = httpClient()) { diff --git a/src/test/java/com/retailsvc/http/HandlersTest.java b/src/test/java/com/retailsvc/http/HandlersTest.java index b4004fb..aa2963e 100644 --- a/src/test/java/com/retailsvc/http/HandlersTest.java +++ b/src/test/java/com/retailsvc/http/HandlersTest.java @@ -6,10 +6,17 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import com.retailsvc.http.internal.BodyWriter; import com.retailsvc.http.spec.HttpMethod; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.Map; import java.util.function.UnaryOperator; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; class HandlersTest { @@ -43,44 +50,103 @@ void aliveHandlerReturns405OnPost() { } @Test - void specHandlerServesYamlBytesWithInferredContentType() { - Response resp = Handlers.specHandler("/openapi.yaml").handle(request(GET)); + void resourceHandlerStreamsYamlBytesWithInferredContentType() throws IOException { + Response resp = Handlers.resourceHandler("/openapi.yaml").handle(request(GET)); assertThat(resp.status()).isEqualTo(200); assertThat(resp.contentType()).isEqualTo("application/yaml"); - assertThat(resp.body()).isInstanceOf(byte[].class); - assertThat((byte[]) resp.body()).isNotEmpty(); + assertThat(write(resp)).isEqualTo(readClasspath("/openapi.yaml")); } @Test - void specHandlerInfersJsonContentType() { - Response resp = Handlers.specHandler("/openapi.json").handle(request(GET)); + void resourceHandlerInfersJsonContentType() { + Response resp = Handlers.resourceHandler("/openapi.json").handle(request(GET)); assertThat(resp.contentType()).isEqualTo("application/json"); } @Test - void specHandlerThrowsAtConstructionForMissingResource() { - assertThatThrownBy(() -> Handlers.specHandler("/does-not-exist.yaml")) + void resourceHandlerThrowsAtConstructionForMissingClasspathResource() { + assertThatThrownBy(() -> Handlers.resourceHandler("/does-not-exist.yaml")) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("/does-not-exist.yaml"); } @Test - void specHandlerReturns405OnPost() { - Response resp = Handlers.specHandler("/openapi.yaml").handle(request(POST)); + void resourceHandlerReturns405OnPost() { + Response resp = Handlers.resourceHandler("/openapi.yaml").handle(request(POST)); assertThat(resp.status()).isEqualTo(405); assertThat(resp.headers()).containsEntry("Allow", "GET, HEAD"); } @Test - void specHandlerHeadReturnsContentLengthWithoutBody() { - Response resp = Handlers.specHandler("/openapi.yaml").handle(request(HEAD)); + void resourceHandlerHeadReturnsContentLengthWithoutBody() { + Response resp = Handlers.resourceHandler("/openapi.yaml").handle(request(HEAD)); assertThat(resp.status()).isEqualTo(200); assertThat(resp.body()).isNull(); assertThat(resp.headers()).containsKey("Content-Length"); assertThat(Integer.parseInt(resp.headers().get("Content-Length"))).isGreaterThan(0); } + + @Test + void resourceHandlerOpensClasspathStreamLazilyPerRequest() throws IOException { + RequestHandler handler = Handlers.resourceHandler("/openapi.yaml"); + + byte[] first = write(handler.handle(request(GET))); + byte[] second = write(handler.handle(request(GET))); + + assertThat(first).isEqualTo(second).isNotEmpty(); + } + + @Test + void resourceHandlerServesFilesystemFile(@TempDir Path tmp) throws IOException { + Path file = tmp.resolve("page.html"); + Files.writeString(file, "

hi

"); + + Response resp = Handlers.resourceHandler(file).handle(request(GET)); + + assertThat(resp.status()).isEqualTo(200); + assertThat(resp.contentType()).isEqualTo("text/html; charset=utf-8"); + assertThat(new String(write(resp))).isEqualTo("

hi

"); + } + + @Test + void resourceHandlerHeadOnFilesystemFileReportsContentLength(@TempDir Path tmp) + throws IOException { + Path file = tmp.resolve("data.txt"); + Files.writeString(file, "hello"); + + Response resp = Handlers.resourceHandler(file).handle(request(HEAD)); + + assertThat(resp.status()).isEqualTo(200); + assertThat(resp.body()).isNull(); + assertThat(resp.headers()).containsEntry("Content-Length", "5"); + } + + @Test + void resourceHandlerThrowsAtConstructionForMissingFile(@TempDir Path tmp) { + Path missing = tmp.resolve("nope.txt"); + + assertThatThrownBy(() -> Handlers.resourceHandler(missing)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("nope.txt"); + } + + private static byte[] write(Response resp) throws IOException { + assertThat(resp.body()).isInstanceOf(BodyWriter.class); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ((BodyWriter) resp.body()).writeTo(out); + return out.toByteArray(); + } + + private static byte[] readClasspath(String path) throws IOException { + try (InputStream in = HandlersTest.class.getResourceAsStream(path)) { + if (in == null) { + throw new IOException("missing fixture: " + path); + } + return in.readAllBytes(); + } + } } diff --git a/src/test/java/com/retailsvc/http/internal/ClasspathResourceHandlerTest.java b/src/test/java/com/retailsvc/http/internal/ClasspathResourceHandlerTest.java deleted file mode 100644 index ad80e77..0000000 --- a/src/test/java/com/retailsvc/http/internal/ClasspathResourceHandlerTest.java +++ /dev/null @@ -1,86 +0,0 @@ -package com.retailsvc.http.internal; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import java.io.IOException; -import java.io.InputStream; -import org.junit.jupiter.api.Test; - -class ClasspathResourceHandlerTest { - - @Test - void getServesBytesVerbatim() throws IOException { - byte[] expected = readResource("/sample.txt"); - - byte[] actual = new ClasspathResourceHandler("/sample.txt").bytes(); - - assertThat(actual).isEqualTo(expected); - } - - @Test - void contentLengthMatchesBytesLength() { - ClasspathResourceHandler handler = new ClasspathResourceHandler("/sample.txt"); - - assertThat(handler.bytes()).hasSize(handler.bytes().length); - } - - @Test - void infersApplicationJsonForJsonExtension() { - assertThat(new ClasspathResourceHandler("/openapi.json").contentType()) - .isEqualTo("application/json"); - } - - @Test - void infersApplicationYamlForYamlExtension() { - assertThat(new ClasspathResourceHandler("/openapi.yaml").contentType()) - .isEqualTo("application/yaml"); - } - - @Test - void infersApplicationYamlForYmlExtension() { - assertThat(new ClasspathResourceHandler("/openapi.yml").contentType()) - .isEqualTo("application/yaml"); - } - - @Test - void infersTextPlainForTxtExtension() { - assertThat(new ClasspathResourceHandler("/sample.txt").contentType()) - .isEqualTo("text/plain; charset=utf-8"); - } - - @Test - void fallsBackToOctetStreamForUnknownExtension() { - assertThat(new ClasspathResourceHandler("/sample.bin").contentType()) - .isEqualTo("application/octet-stream"); - } - - @Test - void missingResourceThrowsIllegalArgumentExceptionWithPathInMessage() { - assertThatThrownBy(() -> new ClasspathResourceHandler("/does-not-exist.json")) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("/does-not-exist.json"); - } - - @Test - void resourceIsLoadedEagerlyAtConstruction() { - // If the resource were loaded lazily, construction would succeed and bytes() would fail. - // Construction itself must fail for missing resources. - assertThatThrownBy(() -> new ClasspathResourceHandler("/missing.txt")) - .isInstanceOf(IllegalArgumentException.class); - } - - @Test - void bytesAreNonEmptyForExistingResource() { - assertThat(new ClasspathResourceHandler("/sample.txt").bytes()).isNotEmpty(); - } - - private static byte[] readResource(String path) throws IOException { - try (InputStream in = ClasspathResourceHandlerTest.class.getResourceAsStream(path)) { - if (in == null) { - throw new IOException("missing fixture: " + path); - } - return in.readAllBytes(); - } - } -} diff --git a/src/test/java/com/retailsvc/http/internal/ResourceSourceTest.java b/src/test/java/com/retailsvc/http/internal/ResourceSourceTest.java new file mode 100644 index 0000000..339df51 --- /dev/null +++ b/src/test/java/com/retailsvc/http/internal/ResourceSourceTest.java @@ -0,0 +1,131 @@ +package com.retailsvc.http.internal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ResourceSourceTest { + + @Test + void ofClasspathReportsLengthAtConstruction() throws IOException { + byte[] expected = readClasspath("/sample.txt"); + + ResourceSource source = ResourceSource.ofClasspath("/sample.txt"); + + assertThat(source.length()).isEqualTo(expected.length); + } + + @Test + void ofClasspathStreamsBytesVerbatim() throws IOException { + byte[] expected = readClasspath("/sample.txt"); + + try (InputStream in = ResourceSource.ofClasspath("/sample.txt").open()) { + assertThat(in.readAllBytes()).isEqualTo(expected); + } + } + + @Test + void ofClasspathOpenReturnsFreshStreamEachCall() throws IOException { + ResourceSource source = ResourceSource.ofClasspath("/sample.txt"); + + byte[] first; + byte[] second; + try (InputStream in = source.open()) { + first = in.readAllBytes(); + } + try (InputStream in = source.open()) { + second = in.readAllBytes(); + } + + assertThat(first).isEqualTo(second).isNotEmpty(); + } + + @Test + void ofClasspathInfersJsonContentType() { + assertThat(ResourceSource.ofClasspath("/openapi.json").contentType()) + .isEqualTo("application/json"); + } + + @Test + void ofClasspathInfersYamlContentType() { + assertThat(ResourceSource.ofClasspath("/openapi.yaml").contentType()) + .isEqualTo("application/yaml"); + } + + @Test + void contentTypeForInfersHtml() { + assertThat(ResourceSource.contentTypeFor("index.html")).isEqualTo("text/html; charset=utf-8"); + } + + @Test + void contentTypeForInfersCss() { + assertThat(ResourceSource.contentTypeFor("style.css")).isEqualTo("text/css; charset=utf-8"); + } + + @Test + void contentTypeForInfersJavascript() { + assertThat(ResourceSource.contentTypeFor("app.js")).isEqualTo("text/javascript; charset=utf-8"); + } + + @Test + void contentTypeForFallsBackToOctetStream() { + assertThat(ResourceSource.contentTypeFor("blob.bin")).isEqualTo("application/octet-stream"); + } + + @Test + void ofClasspathMissingResourceThrowsWithPathInMessage() { + assertThatThrownBy(() -> ResourceSource.ofClasspath("/does-not-exist.json")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("/does-not-exist.json"); + } + + @Test + void ofFileReportsLengthAtConstruction(@TempDir Path tmp) throws IOException { + Path file = tmp.resolve("data.txt"); + Files.writeString(file, "hello"); + + ResourceSource source = ResourceSource.ofFile(file); + + assertThat(source.length()).isEqualTo(5); + } + + @Test + void ofFileStreamsBytesVerbatim(@TempDir Path tmp) throws IOException { + Path file = tmp.resolve("data.txt"); + Files.writeString(file, "hello"); + + try (InputStream in = ResourceSource.ofFile(file).open()) { + assertThat(new String(in.readAllBytes())).isEqualTo("hello"); + } + } + + @Test + void ofFileMissingThrowsWithPathInMessage(@TempDir Path tmp) { + Path missing = tmp.resolve("nope.txt"); + + assertThatThrownBy(() -> ResourceSource.ofFile(missing)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("nope.txt"); + } + + @Test + void ofFileDirectoryThrows(@TempDir Path tmp) { + assertThatThrownBy(() -> ResourceSource.ofFile(tmp)) + .isInstanceOf(IllegalArgumentException.class); + } + + private static byte[] readClasspath(String path) throws IOException { + try (InputStream in = ResourceSourceTest.class.getResourceAsStream(path)) { + if (in == null) { + throw new IOException("missing fixture: " + path); + } + return in.readAllBytes(); + } + } +}