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
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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();
```

Expand All @@ -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

Expand Down
43 changes: 34 additions & 9 deletions src/main/java/com/retailsvc/http/Handlers.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -102,22 +104,45 @@ public static RequestHandler healthHandler(Supplier<HealthOutcome> 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");
};
}
Expand Down

This file was deleted.

105 changes: 105 additions & 0 deletions src/main/java/com/retailsvc/http/internal/ResourceSource.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
4 changes: 2 additions & 2 deletions src/test/java/com/retailsvc/http/ExtraHandlersIT.java
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {

Expand Down
90 changes: 78 additions & 12 deletions src/test/java/com/retailsvc/http/HandlersTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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, "<h1>hi</h1>");

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("<h1>hi</h1>");
}

@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();
}
}
}
Loading
Loading