Skip to content

Commit b1b6c66

Browse files
committed
feat: Add InputStream-based Spec loaders for classpath specs
Spec.fromPath required a filesystem Path, which is awkward for callers loading the spec from the classpath via getResourceAsStream and outright broken when the resource lives inside a JAR. Add four new entry points: - Spec.fromJson(InputStream) — Gson (reflective) - Spec.fromJson(InputStream, Function<byte[],Map>) — BYO parser (Jackson, ...) - Spec.fromYaml(InputStream) — SnakeYAML (reflective) - Spec.fromYaml(InputStream, Function<byte[],Map>) — BYO parser All four consume and close the stream, NPE on null args, and surface IO failures as UncheckedIOException. fromPath now shares the same byte-based internal parsers. Also ignore .claude/worktrees/ so local agent worktrees don't pollute git status.
1 parent e952bc8 commit b1b6c66

3 files changed

Lines changed: 287 additions & 14 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ build/
3636

3737
### Claude Code per-developer settings ###
3838
.claude/settings.local.json
39+
.claude/worktrees/
3940

4041
### Performance recordings ###
4142
perf/

src/main/java/com/retailsvc/http/spec/Spec.java

Lines changed: 101 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,21 @@
66
import com.retailsvc.http.spec.security.SecurityScheme;
77
import com.retailsvc.http.spec.security.SecuritySchemeParser;
88
import java.io.IOException;
9+
import java.io.InputStream;
910
import java.io.UncheckedIOException;
1011
import java.lang.reflect.Method;
1112
import java.net.URI;
13+
import java.nio.charset.StandardCharsets;
1214
import java.nio.file.Files;
1315
import java.nio.file.Path;
1416
import java.util.ArrayList;
1517
import java.util.LinkedHashMap;
1618
import java.util.List;
1719
import java.util.Locale;
1820
import java.util.Map;
21+
import java.util.Objects;
1922
import java.util.Optional;
23+
import java.util.function.Function;
2024

2125
public record Spec(
2226
String openapi,
@@ -67,18 +71,18 @@ static Map<String, Object> extractExtensions(Map<String, Object> raw) {
6771
* has an unrecognised extension
6872
*/
6973
public static Spec fromPath(Path path) {
70-
String text;
74+
byte[] bytes;
7175
try {
72-
text = Files.readString(path);
76+
bytes = Files.readAllBytes(path);
7377
} catch (IOException e) {
7478
throw new UncheckedIOException("Failed to read OpenAPI spec from " + path, e);
7579
}
7680
String name = path.getFileName().toString().toLowerCase(Locale.ROOT);
7781
Map<String, Object> raw;
7882
if (name.endsWith(".json")) {
79-
raw = parseJson(text);
83+
raw = parseJsonWithGson(bytes);
8084
} else if (name.endsWith(".yaml") || name.endsWith(".yml")) {
81-
raw = parseYaml(text);
85+
raw = parseYamlWithSnakeYaml(bytes);
8286
} else {
8387
throw new IllegalStateException(
8488
"Unrecognised OpenAPI spec extension for "
@@ -89,8 +93,89 @@ public static Spec fromPath(Path path) {
8993
return from(raw);
9094
}
9195

92-
private static Map<String, Object> parseJson(String text) {
93-
Class<?> gsonClass = loadOptional(GSON_CLASS, "JSON", "Gson");
96+
/**
97+
* Reads a JSON OpenAPI specification from {@code in} using Gson. Gson must be on the classpath;
98+
* otherwise throws {@link IllegalStateException}. The stream is fully consumed and closed before
99+
* this method returns.
100+
*
101+
* <p>Useful for loading specs from the classpath:
102+
*
103+
* <pre>{@code
104+
* try (InputStream in = getClass().getResourceAsStream("/openapi.json")) {
105+
* Spec spec = Spec.fromJson(in);
106+
* }
107+
* }</pre>
108+
*
109+
* <p>To avoid the Gson dependency (e.g. when using Jackson), use {@link #fromJson(InputStream,
110+
* Function)} instead.
111+
*
112+
* @throws NullPointerException if {@code in} is {@code null}
113+
* @throws UncheckedIOException if the stream cannot be read
114+
* @throws IllegalStateException if Gson is not on the classpath
115+
*/
116+
public static Spec fromJson(InputStream in) {
117+
return fromJson(in, Spec::parseJsonWithGson);
118+
}
119+
120+
/**
121+
* Reads a JSON OpenAPI specification from {@code in} using the supplied {@code parser}. The
122+
* parser receives the full body as bytes and returns the decoded map. The stream is fully
123+
* consumed and closed before this method returns.
124+
*
125+
* <p>Example with Jackson:
126+
*
127+
* <pre>{@code
128+
* ObjectMapper mapper = new ObjectMapper();
129+
* Spec spec = Spec.fromJson(in, bytes -> mapper.readValue(bytes, Map.class));
130+
* }</pre>
131+
*
132+
* @throws NullPointerException if {@code in} or {@code parser} is {@code null}
133+
* @throws UncheckedIOException if the stream cannot be read
134+
*/
135+
public static Spec fromJson(InputStream in, Function<byte[], Map<String, Object>> parser) {
136+
Objects.requireNonNull(parser, "parser");
137+
return from(parser.apply(readAll(in)));
138+
}
139+
140+
/**
141+
* Reads a YAML OpenAPI specification from {@code in} using SnakeYAML. SnakeYAML must be on the
142+
* classpath; otherwise throws {@link IllegalStateException}. The stream is fully consumed and
143+
* closed before this method returns.
144+
*
145+
* <p>To avoid the SnakeYAML dependency, use {@link #fromYaml(InputStream, Function)} instead.
146+
*
147+
* @throws NullPointerException if {@code in} is {@code null}
148+
* @throws UncheckedIOException if the stream cannot be read
149+
* @throws IllegalStateException if SnakeYAML is not on the classpath
150+
*/
151+
public static Spec fromYaml(InputStream in) {
152+
return fromYaml(in, Spec::parseYamlWithSnakeYaml);
153+
}
154+
155+
/**
156+
* Reads a YAML OpenAPI specification from {@code in} using the supplied {@code parser}. The
157+
* stream is fully consumed and closed before this method returns.
158+
*
159+
* @throws NullPointerException if {@code in} or {@code parser} is {@code null}
160+
* @throws UncheckedIOException if the stream cannot be read
161+
*/
162+
public static Spec fromYaml(InputStream in, Function<byte[], Map<String, Object>> parser) {
163+
Objects.requireNonNull(parser, "parser");
164+
return from(parser.apply(readAll(in)));
165+
}
166+
167+
private static byte[] readAll(InputStream in) {
168+
Objects.requireNonNull(in, "in");
169+
try (in) {
170+
return in.readAllBytes();
171+
} catch (IOException e) {
172+
throw new UncheckedIOException("Failed to read OpenAPI spec from stream", e);
173+
}
174+
}
175+
176+
private static Map<String, Object> parseJsonWithGson(byte[] bytes) {
177+
String text = new String(bytes, StandardCharsets.UTF_8);
178+
Class<?> gsonClass = loadOptional(GSON_CLASS, "Json", "Gson");
94179
try {
95180
Object gson = gsonClass.getDeclaredConstructor().newInstance();
96181
Method fromJson = gsonClass.getMethod("fromJson", String.class, Class.class);
@@ -102,8 +187,9 @@ private static Map<String, Object> parseJson(String text) {
102187
}
103188
}
104189

105-
private static Map<String, Object> parseYaml(String text) {
106-
Class<?> yamlClass = loadOptional(SNAKEYAML_CLASS, "YAML", "SnakeYAML");
190+
private static Map<String, Object> parseYamlWithSnakeYaml(byte[] bytes) {
191+
String text = new String(bytes, StandardCharsets.UTF_8);
192+
Class<?> yamlClass = loadOptional(SNAKEYAML_CLASS, "Yaml", "SnakeYAML");
107193
try {
108194
Object yaml = yamlClass.getDeclaredConstructor().newInstance();
109195
Method load = yamlClass.getMethod("load", String.class);
@@ -120,14 +206,15 @@ private static Class<?> loadOptional(String className, String format, String lib
120206
return Class.forName(className, false, Spec.class.getClassLoader());
121207
} catch (ClassNotFoundException e) {
122208
throw new IllegalStateException(
123-
"Spec.fromPath requires "
124-
+ libName
125-
+ " on the classpath for "
209+
"Loading "
126210
+ format
127-
+ " specs. Add a "
211+
+ " OpenAPI specs requires "
128212
+ libName
129-
+ " dependency, or parse the file yourself and call"
130-
+ " Spec.from(Map<String, Object>) instead.",
213+
+ " on the classpath. Add a "
214+
+ libName
215+
+ " dependency, or supply your own parser via Spec.from"
216+
+ format
217+
+ "(InputStream, Function) / Spec.from(Map<String, Object>) instead.",
131218
e);
132219
}
133220
}
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
package com.retailsvc.http.spec;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
5+
6+
import com.google.gson.Gson;
7+
import java.io.ByteArrayInputStream;
8+
import java.io.IOException;
9+
import java.io.InputStream;
10+
import java.io.UncheckedIOException;
11+
import java.nio.charset.StandardCharsets;
12+
import java.util.Map;
13+
import java.util.concurrent.atomic.AtomicBoolean;
14+
import java.util.function.Function;
15+
import org.junit.jupiter.api.Test;
16+
import org.yaml.snakeyaml.Yaml;
17+
18+
class SpecFromInputStreamTest {
19+
20+
@Test
21+
void fromJsonLoadsClasspathStreamUsingGson() throws Exception {
22+
try (InputStream in = getClass().getResourceAsStream("/openapi.json")) {
23+
Spec spec = Spec.fromJson(in);
24+
25+
assertThat(spec.openapi()).startsWith("3.1");
26+
assertThat(spec.basePath()).isEqualTo("/api/v1");
27+
assertThat(spec.operations()).isNotEmpty();
28+
}
29+
}
30+
31+
@Test
32+
void fromYamlLoadsClasspathStreamUsingSnakeYaml() throws Exception {
33+
try (InputStream in = getClass().getResourceAsStream("/openapi.yaml")) {
34+
Spec spec = Spec.fromYaml(in);
35+
36+
assertThat(spec.openapi()).startsWith("3.1");
37+
assertThat(spec.operations()).isNotEmpty();
38+
}
39+
}
40+
41+
@Test
42+
void fromJsonWithCustomParserDoesNotRequireGson() throws Exception {
43+
Gson gson = new Gson();
44+
Function<byte[], Map<String, Object>> parser =
45+
bytes -> gson.fromJson(new String(bytes, StandardCharsets.UTF_8), Map.class);
46+
47+
try (InputStream in = getClass().getResourceAsStream("/openapi.json")) {
48+
Spec spec = Spec.fromJson(in, parser);
49+
50+
assertThat(spec.openapi()).startsWith("3.1");
51+
}
52+
}
53+
54+
@Test
55+
void fromYamlWithCustomParserDoesNotRequireSnakeYaml() throws Exception {
56+
Yaml yaml = new Yaml();
57+
Function<byte[], Map<String, Object>> parser =
58+
bytes -> yaml.load(new String(bytes, StandardCharsets.UTF_8));
59+
60+
try (InputStream in = getClass().getResourceAsStream("/openapi.yaml")) {
61+
Spec spec = Spec.fromYaml(in, parser);
62+
63+
assertThat(spec.openapi()).startsWith("3.1");
64+
}
65+
}
66+
67+
@Test
68+
void fromJsonClosesStream() throws Exception {
69+
AtomicBoolean closed = new AtomicBoolean(false);
70+
try (InputStream raw = getClass().getResourceAsStream("/openapi.json")) {
71+
InputStream tracking = closingTracker(raw, closed);
72+
73+
Spec.fromJson(tracking);
74+
75+
assertThat(closed).isTrue();
76+
}
77+
}
78+
79+
@Test
80+
void fromYamlClosesStream() throws Exception {
81+
AtomicBoolean closed = new AtomicBoolean(false);
82+
try (InputStream raw = getClass().getResourceAsStream("/openapi.yaml")) {
83+
InputStream tracking = closingTracker(raw, closed);
84+
85+
Spec.fromYaml(tracking);
86+
87+
assertThat(closed).isTrue();
88+
}
89+
}
90+
91+
@Test
92+
void fromJsonWithParserClosesStream() {
93+
AtomicBoolean closed = new AtomicBoolean(false);
94+
String json =
95+
"{\"openapi\":\"3.1.0\",\"info\":{\"title\":\"t\",\"version\":\"1\"},"
96+
+ "\"servers\":[{\"url\":\"http://localhost/x\"}],\"paths\":{}}";
97+
InputStream tracking =
98+
closingTracker(new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)), closed);
99+
Gson gson = new Gson();
100+
Function<byte[], Map<String, Object>> parser =
101+
bytes -> gson.fromJson(new String(bytes, StandardCharsets.UTF_8), Map.class);
102+
103+
Spec.fromJson(tracking, parser);
104+
105+
assertThat(closed).isTrue();
106+
}
107+
108+
@Test
109+
void fromJsonRejectsNullStream() {
110+
assertThatThrownBy(() -> Spec.fromJson((InputStream) null))
111+
.isInstanceOf(NullPointerException.class);
112+
}
113+
114+
@Test
115+
void fromYamlRejectsNullStream() {
116+
assertThatThrownBy(() -> Spec.fromYaml((InputStream) null))
117+
.isInstanceOf(NullPointerException.class);
118+
}
119+
120+
@Test
121+
void fromJsonWithParserRejectsNullArgs() {
122+
InputStream in = new ByteArrayInputStream(new byte[0]);
123+
Function<byte[], Map<String, Object>> parser = bytes -> Map.of();
124+
125+
assertThatThrownBy(() -> Spec.fromJson(null, parser)).isInstanceOf(NullPointerException.class);
126+
assertThatThrownBy(() -> Spec.fromJson(in, null)).isInstanceOf(NullPointerException.class);
127+
}
128+
129+
@Test
130+
void fromYamlWithParserRejectsNullArgs() {
131+
InputStream in = new ByteArrayInputStream(new byte[0]);
132+
Function<byte[], Map<String, Object>> parser = bytes -> Map.of();
133+
134+
assertThatThrownBy(() -> Spec.fromYaml(null, parser)).isInstanceOf(NullPointerException.class);
135+
assertThatThrownBy(() -> Spec.fromYaml(in, null)).isInstanceOf(NullPointerException.class);
136+
}
137+
138+
@Test
139+
void fromJsonPropagatesIoFailure() {
140+
InputStream broken = brokenStream();
141+
142+
assertThatThrownBy(() -> Spec.fromJson(broken)).isInstanceOf(UncheckedIOException.class);
143+
}
144+
145+
@Test
146+
void fromYamlPropagatesIoFailure() {
147+
InputStream broken = brokenStream();
148+
149+
assertThatThrownBy(() -> Spec.fromYaml(broken)).isInstanceOf(UncheckedIOException.class);
150+
}
151+
152+
private static InputStream closingTracker(InputStream delegate, AtomicBoolean flag) {
153+
return new InputStream() {
154+
@Override
155+
public int read() throws IOException {
156+
return delegate.read();
157+
}
158+
159+
@Override
160+
public int read(byte[] b, int off, int len) throws IOException {
161+
return delegate.read(b, off, len);
162+
}
163+
164+
@Override
165+
public void close() throws IOException {
166+
flag.set(true);
167+
delegate.close();
168+
}
169+
};
170+
}
171+
172+
private static InputStream brokenStream() {
173+
return new InputStream() {
174+
@Override
175+
public int read() throws IOException {
176+
throw new IOException("boom");
177+
}
178+
179+
@Override
180+
public int read(byte[] b, int off, int len) throws IOException {
181+
throw new IOException("boom");
182+
}
183+
};
184+
}
185+
}

0 commit comments

Comments
 (0)