From 074df3bdc6fd2720c30aa41f93b5a0ad6905f5ef Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Fri, 22 May 2026 12:21:29 +0200 Subject: [PATCH 1/2] feat: Replace Spec.fromPath with Spec.fromClasspath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 39 +++++++++----- .../java/com/retailsvc/http/spec/Spec.java | 49 ++++++++--------- .../http/spec/SpecFromClasspathTest.java | 48 +++++++++++++++++ .../retailsvc/http/spec/SpecFromPathTest.java | 53 ------------------- 4 files changed, 96 insertions(+), 93 deletions(-) create mode 100644 src/test/java/com/retailsvc/http/spec/SpecFromClasspathTest.java delete mode 100644 src/test/java/com/retailsvc/http/spec/SpecFromPathTest.java diff --git a/README.md b/README.md index e7403e77..98d976d9 100644 --- a/README.md +++ b/README.md @@ -144,8 +144,8 @@ Handlers are registered in a `Map` keyed by OpenAPI `ope ``` java public class YourServerLauncher { public static void main(String[] args) throws Exception { - // Gson is on the classpath, so we can load the spec in one line. - Spec spec = Spec.fromPath(Path.of("openapi.json")); + // openapi.json lives in src/main/resources/, so it ships at the JAR root. + Spec spec = Spec.fromClasspath(YourServerLauncher.class, "/openapi.json"); Map handlers = new HashMap<>(); handlers.put("get-data", getDataHandler); @@ -162,22 +162,35 @@ public class YourServerLauncher { ## Spec loading -`Spec.fromPath(Path)` picks the parser by file extension: `.json` is parsed by Gson, `.yaml` / -`.yml` by SnakeYAML. Both are optional dependencies — the same Gson that powers the built-in JSON +`Spec.fromClasspath(Class, String)` is the recommended way to load a spec packaged with your +application. It picks the parser by file extension: `.json` is parsed by Gson, `.yaml` / `.yml` +by SnakeYAML. Both are optional dependencies — 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)` instead. Any other extension is rejected. +isn't on the classpath the call fails with `IllegalStateException`; parse the resource yourself +and use `Spec.from(Map)` instead. Any other extension is rejected. -To load a spec from the classpath (including from inside a JAR) use the `InputStream` overloads: +``` java +// Spec at src/main/resources/openapi.json → JAR root → absolute path. +Spec spec = Spec.fromClasspath(YourServerLauncher.class, "/openapi.json"); +``` + +**Mind the leading slash.** `fromClasspath` resolves the resource via +`Class.getResourceAsStream`, which is package-relative *unless* the name starts with `/`. So +`"/openapi.yaml"` means "JAR root" (typical for `src/main/resources/openapi.yaml`), while +`"openapi.yaml"` means "next to `YourServerLauncher.class`" — i.e. the file must live under +`src/main/resources//openapi.yaml`. Easy to miss; if you get +`IllegalArgumentException: classpath resource not found`, the slash is the first thing to check. + +If you already have the bytes or are loading from somewhere other than the classpath, the +`InputStream` overloads work too — both close the stream before returning: ``` java Spec spec; -try (InputStream in = YourServerLauncher.class.getResourceAsStream("/openapi.json")) { - spec = Spec.fromJson(in); // Gson on the classpath +try (InputStream in = Files.newInputStream(Path.of("openapi.json"))) { + spec = Spec.fromJson(in); // or Spec.fromYaml(in) } ``` -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: ``` java @@ -965,7 +978,7 @@ public final class App { static final ScopedValue CORRELATION_ID = ScopedValue.newInstance(); public static void main(String[] args) throws Exception { - Spec spec = Spec.fromPath(Path.of("openapi.yaml")); // SnakeYAML parses the spec + Spec spec = Spec.fromClasspath(App.class, "/openapi.yaml"); // SnakeYAML parses the spec RequestHandler getPromotion = req -> { String id = req.pathParam("id"); @@ -998,8 +1011,8 @@ What the example demonstrates: - **Gson is the default JSON serializer.** No explicit `bodyMapper(...)` call — the library auto-registers `GsonJsonMapper` for request and response JSON because Gson is on the classpath. -- **SnakeYAML parses the spec.** `Spec.fromPath(...)` picks the parser by file extension; `.yaml` - here means SnakeYAML, and Gson would handle `.json` the same way. +- **SnakeYAML parses the spec.** `Spec.fromClasspath(...)` picks the parser by file extension; + `.yaml` here means SnakeYAML, and Gson would handle `.json` the same way. - **One interceptor sets cross-cutting context.** `ScopedValue.where(...).call(next::proceed)` runs the handler (and any inner interceptors and decorators) inside the binding, so `TENANT.get()` and `CORRELATION_ID.get()` work anywhere they're called. diff --git a/src/main/java/com/retailsvc/http/spec/Spec.java b/src/main/java/com/retailsvc/http/spec/Spec.java index 4f76fc3f..89266e05 100644 --- a/src/main/java/com/retailsvc/http/spec/Spec.java +++ b/src/main/java/com/retailsvc/http/spec/Spec.java @@ -11,8 +11,6 @@ import java.lang.reflect.Method; import java.net.URI; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -55,42 +53,39 @@ static Map extractExtensions(Map raw) { private static final String SNAKEYAML_CLASS = "org.yaml.snakeyaml.Yaml"; /** - * Reads an OpenAPI specification from {@code path}. Picks the parser by file extension: + * Loads an OpenAPI specification from a classpath resource. Picks the parser by file extension: * *
    *
  • {@code .json} → Gson must be on the classpath. *
  • {@code .yaml} or {@code .yml} → SnakeYAML must be on the classpath. *
* - *

Both Gson and SnakeYAML are optional dependencies of this library. If the parser for the - * file's extension is not present, throws {@link IllegalStateException} — register your own - * parser and call {@link #from(Map)} instead. + *

{@code resource} is resolved via {@link Class#getResourceAsStream(String)}: a leading {@code + * /} is absolute (JAR root), otherwise it is package-relative to {@code loader}. Use {@code + * "/openapi.yaml"} for a spec packaged at the root of {@code src/main/resources/}. * - * @throws UncheckedIOException if the file cannot be read - * @throws IllegalStateException if the required parser is not on the classpath, or if the file - * has an unrecognised extension + * @throws NullPointerException if {@code loader} or {@code resource} is {@code null} + * @throws IllegalArgumentException if the resource is not found on the classpath + * @throws IllegalStateException if the file has an unrecognised extension, or the required parser + * is not on the classpath */ - public static Spec fromPath(Path path) { - byte[] bytes; - try { - bytes = Files.readAllBytes(path); - } catch (IOException e) { - throw new UncheckedIOException("Failed to read OpenAPI spec from " + path, e); - } - String name = path.getFileName().toString().toLowerCase(Locale.ROOT); - Map raw; - if (name.endsWith(".json")) { - raw = parseJsonWithGson(bytes); - } else if (name.endsWith(".yaml") || name.endsWith(".yml")) { - raw = parseYamlWithSnakeYaml(bytes); - } else { + public static Spec fromClasspath(Class loader, String resource) { + Objects.requireNonNull(loader, "loader"); + Objects.requireNonNull(resource, "resource"); + String name = resource.toLowerCase(Locale.ROOT); + boolean isJson = name.endsWith(".json"); + boolean isYaml = name.endsWith(".yaml") || name.endsWith(".yml"); + if (!isJson && !isYaml) { throw new IllegalStateException( "Unrecognised OpenAPI spec extension for " - + path - + " — expected .json, .yaml, or .yml. Parse the file yourself and call" - + " Spec.from(Map) instead."); + + resource + + " — expected .json, .yaml, or .yml."); + } + InputStream in = loader.getResourceAsStream(resource); + if (in == null) { + throw new IllegalArgumentException("classpath resource not found: " + resource); } - return from(raw); + return isJson ? fromJson(in) : fromYaml(in); } /** diff --git a/src/test/java/com/retailsvc/http/spec/SpecFromClasspathTest.java b/src/test/java/com/retailsvc/http/spec/SpecFromClasspathTest.java new file mode 100644 index 00000000..6d051b23 --- /dev/null +++ b/src/test/java/com/retailsvc/http/spec/SpecFromClasspathTest.java @@ -0,0 +1,48 @@ +package com.retailsvc.http.spec; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +class SpecFromClasspathTest { + + @Test + void loadsJsonSpecFromClasspath() { + Spec spec = Spec.fromClasspath(getClass(), "/openapi.json"); + + assertThat(spec.openapi()).startsWith("3.1"); + assertThat(spec.basePath()).isEqualTo("/api/v1"); + assertThat(spec.operations()).isNotEmpty(); + } + + @Test + void loadsYamlSpecFromClasspath() { + Spec spec = Spec.fromClasspath(getClass(), "/openapi.yaml"); + + assertThat(spec.openapi()).startsWith("3.1"); + assertThat(spec.operations()).isNotEmpty(); + } + + @Test + void loadsYmlSpecFromClasspath() { + Spec spec = Spec.fromClasspath(getClass(), "/openapi.yml"); + + assertThat(spec.openapi()).startsWith("3.1"); + assertThat(spec.operations()).isNotEmpty(); + } + + @Test + void rejectsMissingResource() { + assertThatThrownBy(() -> Spec.fromClasspath(getClass(), "/does-not-exist.yaml")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("classpath resource not found"); + } + + @Test + void rejectsUnknownExtension() { + assertThatThrownBy(() -> Spec.fromClasspath(getClass(), "/openapi.txt")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Unrecognised OpenAPI spec extension"); + } +} diff --git a/src/test/java/com/retailsvc/http/spec/SpecFromPathTest.java b/src/test/java/com/retailsvc/http/spec/SpecFromPathTest.java deleted file mode 100644 index 17e93037..00000000 --- a/src/test/java/com/retailsvc/http/spec/SpecFromPathTest.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.retailsvc.http.spec; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import java.nio.file.Files; -import java.nio.file.Path; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -class SpecFromPathTest { - - @Test - void loadsJsonSpecViaGson() throws Exception { - Path resource = Path.of(getClass().getResource("/openapi.json").toURI()); - - Spec spec = Spec.fromPath(resource); - - assertThat(spec.openapi()).startsWith("3.1"); - assertThat(spec.basePath()).isEqualTo("/api/v1"); - assertThat(spec.operations()).isNotEmpty(); - } - - @Test - void loadsYamlSpecViaSnakeYaml() throws Exception { - Path resource = Path.of(getClass().getResource("/openapi.yaml").toURI()); - - Spec spec = Spec.fromPath(resource); - - assertThat(spec.openapi()).startsWith("3.1"); - assertThat(spec.operations()).isNotEmpty(); - } - - @Test - void loadsYmlSpecViaSnakeYaml() throws Exception { - Path resource = Path.of(getClass().getResource("/openapi.yml").toURI()); - - Spec spec = Spec.fromPath(resource); - - assertThat(spec.openapi()).startsWith("3.1"); - assertThat(spec.operations()).isNotEmpty(); - } - - @Test - void rejectsUnknownExtension(@TempDir Path tmp) throws Exception { - Path unknown = tmp.resolve("spec.txt"); - Files.writeString(unknown, "{}"); - - assertThatThrownBy(() -> Spec.fromPath(unknown)) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("Unrecognised OpenAPI spec extension"); - } -} From 68286b5b01bd2792ce0b6919f2e5958204eda761 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Fri, 22 May 2026 12:25:16 +0200 Subject: [PATCH 2/2] fix: Hoist getClass() out of assertThatThrownBy lambdas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sonar S5778 — each lambda should have at most one invocation that can throw a runtime exception. Both getClass() and Spec.fromClasspath were inside the lambda; the call we're actually asserting against is Spec.fromClasspath, so pull the class lookup out into a local first. --- .../java/com/retailsvc/http/spec/SpecFromClasspathTest.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/retailsvc/http/spec/SpecFromClasspathTest.java b/src/test/java/com/retailsvc/http/spec/SpecFromClasspathTest.java index 6d051b23..0da8b272 100644 --- a/src/test/java/com/retailsvc/http/spec/SpecFromClasspathTest.java +++ b/src/test/java/com/retailsvc/http/spec/SpecFromClasspathTest.java @@ -34,14 +34,16 @@ void loadsYmlSpecFromClasspath() { @Test void rejectsMissingResource() { - assertThatThrownBy(() -> Spec.fromClasspath(getClass(), "/does-not-exist.yaml")) + Class loader = getClass(); + assertThatThrownBy(() -> Spec.fromClasspath(loader, "/does-not-exist.yaml")) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("classpath resource not found"); } @Test void rejectsUnknownExtension() { - assertThatThrownBy(() -> Spec.fromClasspath(getClass(), "/openapi.txt")) + Class loader = getClass(); + assertThatThrownBy(() -> Spec.fromClasspath(loader, "/openapi.txt")) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("Unrecognised OpenAPI spec extension"); }