Skip to content

Commit 897d079

Browse files
authored
feat: Replace Spec.fromPath with Spec.fromClasspath (#94)
* feat: Replace Spec.fromPath with Spec.fromClasspath The filesystem-path loader was awkward for the common case — every service ships its OpenAPI spec inside its own JAR. fromClasspath takes a Class<?> + resource name and dispatches on extension the same way, making the JAR-packaged case a one-liner. Removes fromPath(Path) and its test; adds fromClasspath(Class<?>, String) with tests covering JSON, YAML, YML, missing-resource, and unknown-extension cases. README updated with the new API and a note on the leading-slash gotcha in Class.getResourceAsStream.
1 parent 14d3bf5 commit 897d079

4 files changed

Lines changed: 98 additions & 93 deletions

File tree

README.md

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -144,8 +144,8 @@ Handlers are registered in a `Map<String, RequestHandler>` keyed by OpenAPI `ope
144144
``` java
145145
public class YourServerLauncher {
146146
public static void main(String[] args) throws Exception {
147-
// Gson is on the classpath, so we can load the spec in one line.
148-
Spec spec = Spec.fromPath(Path.of("openapi.json"));
147+
// openapi.json lives in src/main/resources/, so it ships at the JAR root.
148+
Spec spec = Spec.fromClasspath(YourServerLauncher.class, "/openapi.json");
149149

150150
Map<String, RequestHandler> handlers = new HashMap<>();
151151
handlers.put("get-data", getDataHandler);
@@ -162,22 +162,35 @@ public class YourServerLauncher {
162162

163163
## Spec loading
164164

165-
`Spec.fromPath(Path)` picks the parser by file extension: `.json` is parsed by Gson, `.yaml` /
166-
`.yml` by SnakeYAML. Both are optional dependencies — the same Gson that powers the built-in JSON
165+
`Spec.fromClasspath(Class<?>, String)` is the recommended way to load a spec packaged with your
166+
application. It picks the parser by file extension: `.json` is parsed by Gson, `.yaml` / `.yml`
167+
by SnakeYAML. Both are optional dependencies — the same Gson that powers the built-in JSON
167168
`TypeMapper`, and the same SnakeYAML you'd add explicitly to parse YAML. If the required parser
168-
isn't on the classpath the call fails with `IllegalStateException`; parse the file yourself and
169-
use `Spec.from(Map<String, Object>)` instead. Any other extension is rejected.
169+
isn't on the classpath the call fails with `IllegalStateException`; parse the resource yourself
170+
and use `Spec.from(Map<String, Object>)` instead. Any other extension is rejected.
170171

171-
To load a spec from the classpath (including from inside a JAR) use the `InputStream` overloads:
172+
``` java
173+
// Spec at src/main/resources/openapi.json → JAR root → absolute path.
174+
Spec spec = Spec.fromClasspath(YourServerLauncher.class, "/openapi.json");
175+
```
176+
177+
**Mind the leading slash.** `fromClasspath` resolves the resource via
178+
`Class.getResourceAsStream`, which is package-relative *unless* the name starts with `/`. So
179+
`"/openapi.yaml"` means "JAR root" (typical for `src/main/resources/openapi.yaml`), while
180+
`"openapi.yaml"` means "next to `YourServerLauncher.class`" — i.e. the file must live under
181+
`src/main/resources/<your/package>/openapi.yaml`. Easy to miss; if you get
182+
`IllegalArgumentException: classpath resource not found`, the slash is the first thing to check.
183+
184+
If you already have the bytes or are loading from somewhere other than the classpath, the
185+
`InputStream` overloads work too — both close the stream before returning:
172186

173187
``` java
174188
Spec spec;
175-
try (InputStream in = YourServerLauncher.class.getResourceAsStream("/openapi.json")) {
176-
spec = Spec.fromJson(in); // Gson on the classpath
189+
try (InputStream in = Files.newInputStream(Path.of("openapi.json"))) {
190+
spec = Spec.fromJson(in); // or Spec.fromYaml(in)
177191
}
178192
```
179193

180-
The matching `Spec.fromYaml(InputStream)` uses SnakeYAML. Both close the stream before returning.
181194
If you can't (or don't want to) depend on Gson, supply your own JSON parser:
182195

183196
``` java
@@ -965,7 +978,7 @@ public final class App {
965978
static final ScopedValue<String> CORRELATION_ID = ScopedValue.newInstance();
966979
967980
public static void main(String[] args) throws Exception {
968-
Spec spec = Spec.fromPath(Path.of("openapi.yaml")); // SnakeYAML parses the spec
981+
Spec spec = Spec.fromClasspath(App.class, "/openapi.yaml"); // SnakeYAML parses the spec
969982
970983
RequestHandler getPromotion = req -> {
971984
String id = req.pathParam("id");
@@ -998,8 +1011,8 @@ What the example demonstrates:
9981011

9991012
- **Gson is the default JSON serializer.** No explicit `bodyMapper(...)` call — the library
10001013
auto-registers `GsonJsonMapper` for request and response JSON because Gson is on the classpath.
1001-
- **SnakeYAML parses the spec.** `Spec.fromPath(...)` picks the parser by file extension; `.yaml`
1002-
here means SnakeYAML, and Gson would handle `.json` the same way.
1014+
- **SnakeYAML parses the spec.** `Spec.fromClasspath(...)` picks the parser by file extension;
1015+
`.yaml` here means SnakeYAML, and Gson would handle `.json` the same way.
10031016
- **One interceptor sets cross-cutting context.** `ScopedValue.where(...).call(next::proceed)`
10041017
runs the handler (and any inner interceptors and decorators) inside the binding, so
10051018
`TENANT.get()` and `CORRELATION_ID.get()` work anywhere they're called.

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

Lines changed: 22 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@
1111
import java.lang.reflect.Method;
1212
import java.net.URI;
1313
import java.nio.charset.StandardCharsets;
14-
import java.nio.file.Files;
15-
import java.nio.file.Path;
1614
import java.util.ArrayList;
1715
import java.util.LinkedHashMap;
1816
import java.util.List;
@@ -55,42 +53,39 @@ static Map<String, Object> extractExtensions(Map<String, Object> raw) {
5553
private static final String SNAKEYAML_CLASS = "org.yaml.snakeyaml.Yaml";
5654

5755
/**
58-
* Reads an OpenAPI specification from {@code path}. Picks the parser by file extension:
56+
* Loads an OpenAPI specification from a classpath resource. Picks the parser by file extension:
5957
*
6058
* <ul>
6159
* <li>{@code .json} → Gson must be on the classpath.
6260
* <li>{@code .yaml} or {@code .yml} → SnakeYAML must be on the classpath.
6361
* </ul>
6462
*
65-
* <p>Both Gson and SnakeYAML are optional dependencies of this library. If the parser for the
66-
* file's extension is not present, throws {@link IllegalStateException} — register your own
67-
* parser and call {@link #from(Map)} instead.
63+
* <p>{@code resource} is resolved via {@link Class#getResourceAsStream(String)}: a leading {@code
64+
* /} is absolute (JAR root), otherwise it is package-relative to {@code loader}. Use {@code
65+
* "/openapi.yaml"} for a spec packaged at the root of {@code src/main/resources/}.
6866
*
69-
* @throws UncheckedIOException if the file cannot be read
70-
* @throws IllegalStateException if the required parser is not on the classpath, or if the file
71-
* has an unrecognised extension
67+
* @throws NullPointerException if {@code loader} or {@code resource} is {@code null}
68+
* @throws IllegalArgumentException if the resource is not found on the classpath
69+
* @throws IllegalStateException if the file has an unrecognised extension, or the required parser
70+
* is not on the classpath
7271
*/
73-
public static Spec fromPath(Path path) {
74-
byte[] bytes;
75-
try {
76-
bytes = Files.readAllBytes(path);
77-
} catch (IOException e) {
78-
throw new UncheckedIOException("Failed to read OpenAPI spec from " + path, e);
79-
}
80-
String name = path.getFileName().toString().toLowerCase(Locale.ROOT);
81-
Map<String, Object> raw;
82-
if (name.endsWith(".json")) {
83-
raw = parseJsonWithGson(bytes);
84-
} else if (name.endsWith(".yaml") || name.endsWith(".yml")) {
85-
raw = parseYamlWithSnakeYaml(bytes);
86-
} else {
72+
public static Spec fromClasspath(Class<?> loader, String resource) {
73+
Objects.requireNonNull(loader, "loader");
74+
Objects.requireNonNull(resource, "resource");
75+
String name = resource.toLowerCase(Locale.ROOT);
76+
boolean isJson = name.endsWith(".json");
77+
boolean isYaml = name.endsWith(".yaml") || name.endsWith(".yml");
78+
if (!isJson && !isYaml) {
8779
throw new IllegalStateException(
8880
"Unrecognised OpenAPI spec extension for "
89-
+ path
90-
+ " — expected .json, .yaml, or .yml. Parse the file yourself and call"
91-
+ " Spec.from(Map<String, Object>) instead.");
81+
+ resource
82+
+ " — expected .json, .yaml, or .yml.");
83+
}
84+
InputStream in = loader.getResourceAsStream(resource);
85+
if (in == null) {
86+
throw new IllegalArgumentException("classpath resource not found: " + resource);
9287
}
93-
return from(raw);
88+
return isJson ? fromJson(in) : fromYaml(in);
9489
}
9590

9691
/**
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
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 org.junit.jupiter.api.Test;
7+
8+
class SpecFromClasspathTest {
9+
10+
@Test
11+
void loadsJsonSpecFromClasspath() {
12+
Spec spec = Spec.fromClasspath(getClass(), "/openapi.json");
13+
14+
assertThat(spec.openapi()).startsWith("3.1");
15+
assertThat(spec.basePath()).isEqualTo("/api/v1");
16+
assertThat(spec.operations()).isNotEmpty();
17+
}
18+
19+
@Test
20+
void loadsYamlSpecFromClasspath() {
21+
Spec spec = Spec.fromClasspath(getClass(), "/openapi.yaml");
22+
23+
assertThat(spec.openapi()).startsWith("3.1");
24+
assertThat(spec.operations()).isNotEmpty();
25+
}
26+
27+
@Test
28+
void loadsYmlSpecFromClasspath() {
29+
Spec spec = Spec.fromClasspath(getClass(), "/openapi.yml");
30+
31+
assertThat(spec.openapi()).startsWith("3.1");
32+
assertThat(spec.operations()).isNotEmpty();
33+
}
34+
35+
@Test
36+
void rejectsMissingResource() {
37+
Class<?> loader = getClass();
38+
assertThatThrownBy(() -> Spec.fromClasspath(loader, "/does-not-exist.yaml"))
39+
.isInstanceOf(IllegalArgumentException.class)
40+
.hasMessageContaining("classpath resource not found");
41+
}
42+
43+
@Test
44+
void rejectsUnknownExtension() {
45+
Class<?> loader = getClass();
46+
assertThatThrownBy(() -> Spec.fromClasspath(loader, "/openapi.txt"))
47+
.isInstanceOf(IllegalStateException.class)
48+
.hasMessageContaining("Unrecognised OpenAPI spec extension");
49+
}
50+
}

src/test/java/com/retailsvc/http/spec/SpecFromPathTest.java

Lines changed: 0 additions & 53 deletions
This file was deleted.

0 commit comments

Comments
 (0)