Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 26 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,8 @@ Handlers are registered in a `Map<String, RequestHandler>` 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<String, RequestHandler> handlers = new HashMap<>();
handlers.put("get-data", getDataHandler);
Expand All @@ -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<String, Object>)` 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<String, Object>)` 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/<your/package>/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
Expand Down Expand Up @@ -965,7 +978,7 @@ public final class App {
static final ScopedValue<String> 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");
Expand Down Expand Up @@ -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.
Expand Down
49 changes: 22 additions & 27 deletions src/main/java/com/retailsvc/http/spec/Spec.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -55,42 +53,39 @@ static Map<String, Object> extractExtensions(Map<String, Object> 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:
*
* <ul>
* <li>{@code .json} → Gson must be on the classpath.
* <li>{@code .yaml} or {@code .yml} → SnakeYAML must be on the classpath.
* </ul>
*
* <p>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.
* <p>{@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<String, Object> 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<String, Object>) 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);
}

/**
Expand Down
50 changes: 50 additions & 0 deletions src/test/java/com/retailsvc/http/spec/SpecFromClasspathTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
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() {
Class<?> loader = getClass();
assertThatThrownBy(() -> Spec.fromClasspath(loader, "/does-not-exist.yaml"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("classpath resource not found");
}

@Test
void rejectsUnknownExtension() {
Class<?> loader = getClass();
assertThatThrownBy(() -> Spec.fromClasspath(loader, "/openapi.txt"))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("Unrecognised OpenAPI spec extension");
}
}
53 changes: 0 additions & 53 deletions src/test/java/com/retailsvc/http/spec/SpecFromPathTest.java

This file was deleted.

Loading