Skip to content

Commit 7d99cea

Browse files
authored
fix: Extend spec loading (#72)
1 parent 438b247 commit 7d99cea

4 files changed

Lines changed: 271 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/

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,27 @@ public class YourServerLauncher {
125125

126126
`Spec.fromPath(Path)` picks the parser by file extension: `.json` is parsed by Gson, `.yaml` / `.yml` by SnakeYAML. Both are optional dependencies of this library — the same Gson that powers the built-in JSON `TypeMapper`, and the same SnakeYAML you'd add explicitly to parse YAML. If the required parser isn't on the classpath the call fails with `IllegalStateException`; parse the file yourself and use `Spec.from(Map<String, Object>)` instead. Any other extension is rejected.
127127

128+
To load a spec from the classpath (including from inside a JAR) use the `InputStream` overloads:
129+
130+
``` java
131+
Spec spec;
132+
try (InputStream in = YourServerLauncher.class.getResourceAsStream("/openapi.json")) {
133+
spec = Spec.fromJson(in); // Gson on the classpath
134+
}
135+
```
136+
137+
The matching `Spec.fromYaml(InputStream)` uses SnakeYAML. Both close the stream before returning. If you can't (or don't want to) depend on Gson, supply your own JSON parser:
138+
139+
``` java
140+
ObjectMapper jackson = new ObjectMapper();
141+
Spec spec;
142+
try (InputStream in = YourServerLauncher.class.getResourceAsStream("/openapi.json")) {
143+
spec = Spec.fromJson(in, bytes -> jackson.readValue(bytes, Map.class));
144+
}
145+
```
146+
147+
YAML always parses through SnakeYAML — there's no parser-injecting overload. If you want a different YAML library, decode the stream yourself and call `Spec.from(Map<String, Object>)`.
148+
128149
### JSON mapping
129150

130151
The library ships an internal `GsonJsonMapper` that is auto-registered for `application/json` when Gson is on the classpath and no user-supplied JSON mapper has been registered. It:

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

Lines changed: 87 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,75 @@ 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+
* @throws NullPointerException if {@code in} is {@code null}
146+
* @throws UncheckedIOException if the stream cannot be read
147+
* @throws IllegalStateException if SnakeYAML is not on the classpath
148+
*/
149+
public static Spec fromYaml(InputStream in) {
150+
return from(parseYamlWithSnakeYaml(readAll(in)));
151+
}
152+
153+
private static byte[] readAll(InputStream in) {
154+
Objects.requireNonNull(in, "in");
155+
try (in) {
156+
return in.readAllBytes();
157+
} catch (IOException e) {
158+
throw new UncheckedIOException("Failed to read OpenAPI spec from stream", e);
159+
}
160+
}
161+
162+
private static Map<String, Object> parseJsonWithGson(byte[] bytes) {
163+
String text = new String(bytes, StandardCharsets.UTF_8);
164+
Class<?> gsonClass = loadOptional(GSON_CLASS, "Json", "Gson");
94165
try {
95166
Object gson = gsonClass.getDeclaredConstructor().newInstance();
96167
Method fromJson = gsonClass.getMethod("fromJson", String.class, Class.class);
@@ -102,8 +173,9 @@ private static Map<String, Object> parseJson(String text) {
102173
}
103174
}
104175

105-
private static Map<String, Object> parseYaml(String text) {
106-
Class<?> yamlClass = loadOptional(SNAKEYAML_CLASS, "YAML", "SnakeYAML");
176+
private static Map<String, Object> parseYamlWithSnakeYaml(byte[] bytes) {
177+
String text = new String(bytes, StandardCharsets.UTF_8);
178+
Class<?> yamlClass = loadOptional(SNAKEYAML_CLASS, "Yaml", "SnakeYAML");
107179
try {
108180
Object yaml = yamlClass.getDeclaredConstructor().newInstance();
109181
Method load = yamlClass.getMethod("load", String.class);
@@ -120,14 +192,15 @@ private static Class<?> loadOptional(String className, String format, String lib
120192
return Class.forName(className, false, Spec.class.getClassLoader());
121193
} catch (ClassNotFoundException e) {
122194
throw new IllegalStateException(
123-
"Spec.fromPath requires "
124-
+ libName
125-
+ " on the classpath for "
195+
"Loading "
126196
+ format
127-
+ " specs. Add a "
197+
+ " OpenAPI specs requires "
198+
+ libName
199+
+ " on the classpath. Add a "
128200
+ libName
129-
+ " dependency, or parse the file yourself and call"
130-
+ " Spec.from(Map<String, Object>) instead.",
201+
+ " dependency, or supply your own parser via Spec.from"
202+
+ format
203+
+ "(InputStream, Function) / Spec.from(Map<String, Object>) instead.",
131204
e);
132205
}
133206
}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
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+
17+
class SpecFromInputStreamTest {
18+
19+
@Test
20+
void fromJsonLoadsClasspathStreamUsingGson() throws Exception {
21+
try (InputStream in = getClass().getResourceAsStream("/openapi.json")) {
22+
Spec spec = Spec.fromJson(in);
23+
24+
assertThat(spec.openapi()).startsWith("3.1");
25+
assertThat(spec.basePath()).isEqualTo("/api/v1");
26+
assertThat(spec.operations()).isNotEmpty();
27+
}
28+
}
29+
30+
@Test
31+
void fromYamlLoadsClasspathStreamUsingSnakeYaml() throws Exception {
32+
try (InputStream in = getClass().getResourceAsStream("/openapi.yaml")) {
33+
Spec spec = Spec.fromYaml(in);
34+
35+
assertThat(spec.openapi()).startsWith("3.1");
36+
assertThat(spec.operations()).isNotEmpty();
37+
}
38+
}
39+
40+
@Test
41+
void fromJsonWithCustomParserDoesNotRequireGson() throws Exception {
42+
Gson gson = new Gson();
43+
Function<byte[], Map<String, Object>> parser =
44+
bytes -> gson.fromJson(new String(bytes, StandardCharsets.UTF_8), Map.class);
45+
46+
try (InputStream in = getClass().getResourceAsStream("/openapi.json")) {
47+
Spec spec = Spec.fromJson(in, parser);
48+
49+
assertThat(spec.openapi()).startsWith("3.1");
50+
}
51+
}
52+
53+
@Test
54+
void fromJsonClosesStream() throws Exception {
55+
AtomicBoolean closed = new AtomicBoolean(false);
56+
try (InputStream raw = getClass().getResourceAsStream("/openapi.json")) {
57+
InputStream tracking = closingTracker(raw, closed);
58+
59+
Spec.fromJson(tracking);
60+
61+
assertThat(closed).isTrue();
62+
}
63+
}
64+
65+
@Test
66+
void fromYamlClosesStream() throws Exception {
67+
AtomicBoolean closed = new AtomicBoolean(false);
68+
try (InputStream raw = getClass().getResourceAsStream("/openapi.yaml")) {
69+
InputStream tracking = closingTracker(raw, closed);
70+
71+
Spec.fromYaml(tracking);
72+
73+
assertThat(closed).isTrue();
74+
}
75+
}
76+
77+
@Test
78+
void fromJsonWithParserClosesStream() {
79+
AtomicBoolean closed = new AtomicBoolean(false);
80+
String json =
81+
"{\"openapi\":\"3.1.0\",\"info\":{\"title\":\"t\",\"version\":\"1\"},"
82+
+ "\"servers\":[{\"url\":\"http://localhost/x\"}],\"paths\":{}}";
83+
InputStream tracking =
84+
closingTracker(new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)), closed);
85+
Gson gson = new Gson();
86+
Function<byte[], Map<String, Object>> parser =
87+
bytes -> gson.fromJson(new String(bytes, StandardCharsets.UTF_8), Map.class);
88+
89+
Spec.fromJson(tracking, parser);
90+
91+
assertThat(closed).isTrue();
92+
}
93+
94+
@Test
95+
void fromJsonRejectsNullStream() {
96+
assertThatThrownBy(() -> Spec.fromJson((InputStream) null))
97+
.isInstanceOf(NullPointerException.class);
98+
}
99+
100+
@Test
101+
void fromYamlRejectsNullStream() {
102+
assertThatThrownBy(() -> Spec.fromYaml((InputStream) null))
103+
.isInstanceOf(NullPointerException.class);
104+
}
105+
106+
@Test
107+
void fromJsonWithParserRejectsNullArgs() {
108+
InputStream in = new ByteArrayInputStream(new byte[0]);
109+
Function<byte[], Map<String, Object>> parser = bytes -> Map.of();
110+
111+
assertThatThrownBy(() -> Spec.fromJson(null, parser)).isInstanceOf(NullPointerException.class);
112+
assertThatThrownBy(() -> Spec.fromJson(in, null)).isInstanceOf(NullPointerException.class);
113+
}
114+
115+
@Test
116+
void fromJsonPropagatesIoFailure() {
117+
InputStream broken = brokenStream();
118+
119+
assertThatThrownBy(() -> Spec.fromJson(broken)).isInstanceOf(UncheckedIOException.class);
120+
}
121+
122+
@Test
123+
void fromYamlPropagatesIoFailure() {
124+
InputStream broken = brokenStream();
125+
126+
assertThatThrownBy(() -> Spec.fromYaml(broken)).isInstanceOf(UncheckedIOException.class);
127+
}
128+
129+
private static InputStream closingTracker(InputStream delegate, AtomicBoolean flag) {
130+
return new InputStream() {
131+
@Override
132+
public int read() throws IOException {
133+
return delegate.read();
134+
}
135+
136+
@Override
137+
public int read(byte[] b, int off, int len) throws IOException {
138+
return delegate.read(b, off, len);
139+
}
140+
141+
@Override
142+
public void close() throws IOException {
143+
flag.set(true);
144+
delegate.close();
145+
}
146+
};
147+
}
148+
149+
private static InputStream brokenStream() {
150+
return new InputStream() {
151+
@Override
152+
public int read() throws IOException {
153+
throw new IOException("boom");
154+
}
155+
156+
@Override
157+
public int read(byte[] b, int off, int len) throws IOException {
158+
throw new IOException("boom");
159+
}
160+
};
161+
}
162+
}

0 commit comments

Comments
 (0)