Skip to content

Commit e5d0369

Browse files
committed
feat: Replace specHandler with streaming resourceHandler
Adds Handlers.resourceHandler(String) and Handlers.resourceHandler(Path) that stream from classpath or filesystem with handler-owned stream lifecycle. Existence and length are resolved at construction; the underlying stream is opened and closed per request, avoiding the try-with-resources-closes-too-early trap with lazy Response.stream writers. Replaces the eager byte-buffering specHandler and removes the internal ClasspathResourceHandler. Content-Type inference now also covers .html, .css, and .js.
1 parent ab7e426 commit e5d0369

8 files changed

Lines changed: 355 additions & 162 deletions

File tree

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -776,7 +776,7 @@ var server = OpenApiServer.builder()
776776
.handlers(handlers)
777777
.extraRoute("/alive", Handlers.aliveHandler())
778778
.extraRoute("/schemas/v1/openapi.yaml",
779-
Handlers.specHandler("/schemas/v1/openapi.yaml"))
779+
Handlers.resourceHandler("/schemas/v1/openapi.yaml"))
780780
.build();
781781
```
782782

@@ -787,8 +787,10 @@ routes.
787787
Built-in helpers:
788788

789789
- `Handlers.aliveHandler()` — 204 No Content on `GET`/`HEAD`, 405 otherwise.
790-
- `Handlers.specHandler(classpathResource)` — serves a classpath resource (content-type inferred
791-
from extension). Throws `IllegalArgumentException` at construction if the resource is missing.
790+
- `Handlers.resourceHandler(classpathResource)` / `Handlers.resourceHandler(Path)` — streams a
791+
classpath resource or filesystem file (content-type inferred from extension; the stream is
792+
opened and closed per request, and the handler owns its lifecycle). Throws
793+
`IllegalArgumentException` at construction if the resource or file is missing.
792794

793795
## End-to-end example
794796

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

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@
99
import static java.net.HttpURLConnection.HTTP_UNAVAILABLE;
1010
import static java.nio.charset.StandardCharsets.UTF_8;
1111

12-
import com.retailsvc.http.internal.ClasspathResourceHandler;
1312
import com.retailsvc.http.internal.HealthRenderer;
1413
import com.retailsvc.http.internal.ProblemDetail;
14+
import com.retailsvc.http.internal.ResourceSource;
15+
import java.io.InputStream;
16+
import java.nio.file.Path;
1517
import java.util.List;
1618
import java.util.Objects;
1719
import java.util.function.Supplier;
@@ -102,22 +104,45 @@ public static RequestHandler healthHandler(Supplier<HealthOutcome> probe) {
102104
}
103105

104106
/**
105-
* Serves a classpath resource. Content-Type is inferred from the file extension. The resource is
106-
* loaded eagerly; a missing resource fails immediately with {@link IllegalArgumentException}.
107+
* Serves a classpath resource as a streaming response. Content-Type is inferred from the file
108+
* extension. Existence and length are resolved at construction; a missing resource fails
109+
* immediately with {@link IllegalArgumentException}. The resource is opened and closed per
110+
* request — the handler owns the stream lifecycle.
107111
*
108112
* @param classpathResource absolute classpath path, e.g. {@code /schemas/v1/openapi.yaml}
109113
*/
110-
public static RequestHandler specHandler(String classpathResource) {
111-
ClasspathResourceHandler resource = new ClasspathResourceHandler(classpathResource);
112-
byte[] bytes = resource.bytes();
113-
String contentType = resource.contentType();
114+
public static RequestHandler resourceHandler(String classpathResource) {
115+
return resourceHandler(ResourceSource.ofClasspath(classpathResource));
116+
}
117+
118+
/**
119+
* Serves a filesystem file as a streaming response. Content-Type is inferred from the file
120+
* extension. Existence and length are resolved at construction; a missing file fails immediately
121+
* with {@link IllegalArgumentException}. The file is opened and closed per request.
122+
*/
123+
public static RequestHandler resourceHandler(Path file) {
124+
return resourceHandler(ResourceSource.ofFile(file));
125+
}
126+
127+
private static RequestHandler resourceHandler(ResourceSource source) {
128+
long length = source.length();
129+
String contentType = source.contentType();
114130
return req ->
115131
switch (req.method()) {
116-
case GET -> Response.bytes(HTTP_OK, bytes, contentType);
132+
case GET ->
133+
Response.stream(
134+
HTTP_OK,
135+
length,
136+
contentType,
137+
out -> {
138+
try (InputStream in = source.open()) {
139+
in.transferTo(out);
140+
}
141+
});
117142
case HEAD ->
118143
Response.status(HTTP_OK)
119144
.withContentType(contentType)
120-
.withHeader("Content-Length", String.valueOf(bytes.length));
145+
.withHeader("Content-Length", String.valueOf(length));
121146
default -> Response.status(HTTP_BAD_METHOD).withHeader("Allow", "GET, HEAD");
122147
};
123148
}

src/main/java/com/retailsvc/http/internal/ClasspathResourceHandler.java

Lines changed: 0 additions & 50 deletions
This file was deleted.
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package com.retailsvc.http.internal;
2+
3+
import java.io.IOException;
4+
import java.io.InputStream;
5+
import java.io.OutputStream;
6+
import java.nio.file.Files;
7+
import java.nio.file.Path;
8+
import java.util.Locale;
9+
import java.util.Objects;
10+
11+
/**
12+
* A streamable, fail-fast handle to a resource on the classpath or filesystem. Existence and length
13+
* are resolved at construction; the underlying bytes are not buffered. Each {@link #open()} call
14+
* returns a fresh {@link InputStream} that the caller must close.
15+
*/
16+
public sealed interface ResourceSource {
17+
18+
long length();
19+
20+
String contentType();
21+
22+
String describe();
23+
24+
InputStream open() throws IOException;
25+
26+
static ResourceSource ofClasspath(String classpathResource) {
27+
Objects.requireNonNull(classpathResource, "classpathResource");
28+
long length;
29+
try (InputStream in = ResourceSource.class.getResourceAsStream(classpathResource)) {
30+
if (in == null) {
31+
throw new IllegalArgumentException("classpath resource not found: " + classpathResource);
32+
}
33+
length = in.transferTo(OutputStream.nullOutputStream());
34+
} catch (IOException io) {
35+
throw new IllegalArgumentException(
36+
"failed reading classpath resource: " + classpathResource, io);
37+
}
38+
return new Classpath(classpathResource, length, contentTypeFor(classpathResource));
39+
}
40+
41+
static ResourceSource ofFile(Path file) {
42+
Objects.requireNonNull(file, "file");
43+
if (!Files.isRegularFile(file)) {
44+
throw new IllegalArgumentException("file not found or not a regular file: " + file);
45+
}
46+
long length;
47+
try {
48+
length = Files.size(file);
49+
} catch (IOException io) {
50+
throw new IllegalArgumentException("failed reading file: " + file, io);
51+
}
52+
return new File(file, length, contentTypeFor(file.getFileName().toString()));
53+
}
54+
55+
static String contentTypeFor(String path) {
56+
String lower = path.toLowerCase(Locale.ROOT);
57+
if (lower.endsWith(".json")) {
58+
return "application/json";
59+
}
60+
if (lower.endsWith(".yaml") || lower.endsWith(".yml")) {
61+
return "application/yaml";
62+
}
63+
if (lower.endsWith(".html") || lower.endsWith(".htm")) {
64+
return "text/html; charset=utf-8";
65+
}
66+
if (lower.endsWith(".css")) {
67+
return "text/css; charset=utf-8";
68+
}
69+
if (lower.endsWith(".js")) {
70+
return "text/javascript; charset=utf-8";
71+
}
72+
if (lower.endsWith(".txt")) {
73+
return "text/plain; charset=utf-8";
74+
}
75+
return "application/octet-stream";
76+
}
77+
78+
record Classpath(String path, long length, String contentType) implements ResourceSource {
79+
@Override
80+
public InputStream open() throws IOException {
81+
InputStream in = ResourceSource.class.getResourceAsStream(path);
82+
if (in == null) {
83+
throw new IOException("classpath resource disappeared: " + path);
84+
}
85+
return in;
86+
}
87+
88+
@Override
89+
public String describe() {
90+
return "classpath:" + path;
91+
}
92+
}
93+
94+
record File(Path path, long length, String contentType) implements ResourceSource {
95+
@Override
96+
public InputStream open() throws IOException {
97+
return Files.newInputStream(path);
98+
}
99+
100+
@Override
101+
public String describe() {
102+
return path.toString();
103+
}
104+
}
105+
}

src/test/java/com/retailsvc/http/ExtraHandlersIT.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,13 @@ void aliveExtraReturns204AndBypassesValidation() throws Exception {
3434
}
3535

3636
@Test
37-
void specHandlerServesClasspathResource() throws Exception {
37+
void resourceHandlerServesClasspathResource() throws Exception {
3838
try (var s =
3939
newBuilder()
4040
.spec(spec)
4141
.handlers(Map.of())
4242
.port(0)
43-
.extraRoute("/openapi.yaml", Handlers.specHandler("/openapi.yaml"))
43+
.extraRoute("/openapi.yaml", Handlers.resourceHandler("/openapi.yaml"))
4444
.build();
4545
var client = httpClient()) {
4646

src/test/java/com/retailsvc/http/HandlersTest.java

Lines changed: 78 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,17 @@
66
import static org.assertj.core.api.Assertions.assertThat;
77
import static org.assertj.core.api.Assertions.assertThatThrownBy;
88

9+
import com.retailsvc.http.internal.BodyWriter;
910
import com.retailsvc.http.spec.HttpMethod;
11+
import java.io.ByteArrayOutputStream;
12+
import java.io.IOException;
13+
import java.io.InputStream;
14+
import java.nio.file.Files;
15+
import java.nio.file.Path;
1016
import java.util.Map;
1117
import java.util.function.UnaryOperator;
1218
import org.junit.jupiter.api.Test;
19+
import org.junit.jupiter.api.io.TempDir;
1320

1421
class HandlersTest {
1522

@@ -43,44 +50,103 @@ void aliveHandlerReturns405OnPost() {
4350
}
4451

4552
@Test
46-
void specHandlerServesYamlBytesWithInferredContentType() {
47-
Response resp = Handlers.specHandler("/openapi.yaml").handle(request(GET));
53+
void resourceHandlerStreamsYamlBytesWithInferredContentType() throws IOException {
54+
Response resp = Handlers.resourceHandler("/openapi.yaml").handle(request(GET));
4855

4956
assertThat(resp.status()).isEqualTo(200);
5057
assertThat(resp.contentType()).isEqualTo("application/yaml");
51-
assertThat(resp.body()).isInstanceOf(byte[].class);
52-
assertThat((byte[]) resp.body()).isNotEmpty();
58+
assertThat(write(resp)).isEqualTo(readClasspath("/openapi.yaml"));
5359
}
5460

5561
@Test
56-
void specHandlerInfersJsonContentType() {
57-
Response resp = Handlers.specHandler("/openapi.json").handle(request(GET));
62+
void resourceHandlerInfersJsonContentType() {
63+
Response resp = Handlers.resourceHandler("/openapi.json").handle(request(GET));
5864

5965
assertThat(resp.contentType()).isEqualTo("application/json");
6066
}
6167

6268
@Test
63-
void specHandlerThrowsAtConstructionForMissingResource() {
64-
assertThatThrownBy(() -> Handlers.specHandler("/does-not-exist.yaml"))
69+
void resourceHandlerThrowsAtConstructionForMissingClasspathResource() {
70+
assertThatThrownBy(() -> Handlers.resourceHandler("/does-not-exist.yaml"))
6571
.isInstanceOf(IllegalArgumentException.class)
6672
.hasMessageContaining("/does-not-exist.yaml");
6773
}
6874

6975
@Test
70-
void specHandlerReturns405OnPost() {
71-
Response resp = Handlers.specHandler("/openapi.yaml").handle(request(POST));
76+
void resourceHandlerReturns405OnPost() {
77+
Response resp = Handlers.resourceHandler("/openapi.yaml").handle(request(POST));
7278

7379
assertThat(resp.status()).isEqualTo(405);
7480
assertThat(resp.headers()).containsEntry("Allow", "GET, HEAD");
7581
}
7682

7783
@Test
78-
void specHandlerHeadReturnsContentLengthWithoutBody() {
79-
Response resp = Handlers.specHandler("/openapi.yaml").handle(request(HEAD));
84+
void resourceHandlerHeadReturnsContentLengthWithoutBody() {
85+
Response resp = Handlers.resourceHandler("/openapi.yaml").handle(request(HEAD));
8086

8187
assertThat(resp.status()).isEqualTo(200);
8288
assertThat(resp.body()).isNull();
8389
assertThat(resp.headers()).containsKey("Content-Length");
8490
assertThat(Integer.parseInt(resp.headers().get("Content-Length"))).isGreaterThan(0);
8591
}
92+
93+
@Test
94+
void resourceHandlerOpensClasspathStreamLazilyPerRequest() throws IOException {
95+
RequestHandler handler = Handlers.resourceHandler("/openapi.yaml");
96+
97+
byte[] first = write(handler.handle(request(GET)));
98+
byte[] second = write(handler.handle(request(GET)));
99+
100+
assertThat(first).isEqualTo(second).isNotEmpty();
101+
}
102+
103+
@Test
104+
void resourceHandlerServesFilesystemFile(@TempDir Path tmp) throws IOException {
105+
Path file = tmp.resolve("page.html");
106+
Files.writeString(file, "<h1>hi</h1>");
107+
108+
Response resp = Handlers.resourceHandler(file).handle(request(GET));
109+
110+
assertThat(resp.status()).isEqualTo(200);
111+
assertThat(resp.contentType()).isEqualTo("text/html; charset=utf-8");
112+
assertThat(new String(write(resp))).isEqualTo("<h1>hi</h1>");
113+
}
114+
115+
@Test
116+
void resourceHandlerHeadOnFilesystemFileReportsContentLength(@TempDir Path tmp)
117+
throws IOException {
118+
Path file = tmp.resolve("data.txt");
119+
Files.writeString(file, "hello");
120+
121+
Response resp = Handlers.resourceHandler(file).handle(request(HEAD));
122+
123+
assertThat(resp.status()).isEqualTo(200);
124+
assertThat(resp.body()).isNull();
125+
assertThat(resp.headers()).containsEntry("Content-Length", "5");
126+
}
127+
128+
@Test
129+
void resourceHandlerThrowsAtConstructionForMissingFile(@TempDir Path tmp) {
130+
Path missing = tmp.resolve("nope.txt");
131+
132+
assertThatThrownBy(() -> Handlers.resourceHandler(missing))
133+
.isInstanceOf(IllegalArgumentException.class)
134+
.hasMessageContaining("nope.txt");
135+
}
136+
137+
private static byte[] write(Response resp) throws IOException {
138+
assertThat(resp.body()).isInstanceOf(BodyWriter.class);
139+
ByteArrayOutputStream out = new ByteArrayOutputStream();
140+
((BodyWriter) resp.body()).writeTo(out);
141+
return out.toByteArray();
142+
}
143+
144+
private static byte[] readClasspath(String path) throws IOException {
145+
try (InputStream in = HandlersTest.class.getResourceAsStream(path)) {
146+
if (in == null) {
147+
throw new IOException("missing fixture: " + path);
148+
}
149+
return in.readAllBytes();
150+
}
151+
}
86152
}

0 commit comments

Comments
 (0)