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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ build/

### Claude Code per-developer settings ###
.claude/settings.local.json
.claude/worktrees/

### Performance recordings ###
perf/
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,27 @@ public class YourServerLauncher {

`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.

To load a spec from the classpath (including from inside a JAR) use the `InputStream` overloads:

``` java
Spec spec;
try (InputStream in = YourServerLauncher.class.getResourceAsStream("/openapi.json")) {
spec = Spec.fromJson(in); // Gson on the classpath
}
Comment thread
thced marked this conversation as resolved.
```

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
ObjectMapper jackson = new ObjectMapper();
Spec spec;
try (InputStream in = YourServerLauncher.class.getResourceAsStream("/openapi.json")) {
spec = Spec.fromJson(in, bytes -> jackson.readValue(bytes, Map.class));
}
```

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>)`.

### JSON mapping

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:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Public GsonTypeMapper

## Problem

`Jackson2JsonTypeMapper` and `Jackson3JsonTypeMapper` are public in
`com.retailsvc.http` and accept a fully-configured `ObjectMapper`, giving
callers direct control over JSON serialization. The Gson equivalent,
`GsonJsonMapper`, lives in `com.retailsvc.http.internal.gson`, is
package-private, has only a no-arg constructor, and hard-codes a Gson
instance with JSR-310 type adapters. Callers cannot pass their own `Gson`
or extend the library's defaults without forking the class.

## Goals

- Expose a public, caller-configurable Gson adapter in `com.retailsvc.http`
that mirrors the Jackson mappers' ergonomics.
- Let callers extend the library's default (JSR-310-aware) Gson without
re-implementing the type adapters.
- Preserve the current auto-registration behavior: putting Gson on the
classpath continues to wire up the JSR-310-aware mapper with no
configuration.

## Non-goals

- Changing the auto-registration mechanism or class-name lookup in
`OpenApiServer`.
- Adding new JSR-310 adapters or changing the existing ISO-8601 format.
- Deprecating or removing `GsonJsonMapper`; it stays as the internal
implementation.

## Design

### Internal refactor: `com.retailsvc.http.internal.gson.GsonJsonMapper`

- Add a constructor `GsonJsonMapper(Gson gson)` that stores the supplied
instance.
- Existing no-arg constructor delegates to `new GsonJsonMapper(defaultGson())`
where `defaultGson()` is a private static method building the current
JSR-310-aware default.
- Add a `Gson gson()` accessor so the public wrapper can reach the
underlying instance.
- `readFrom` / `readAs` / `writeTo` behavior is unchanged.

### New public class: `com.retailsvc.http.GsonTypeMapper`

`public final class GsonTypeMapper implements TypedTypeMapper`

Constructors:

- `GsonTypeMapper()` — wraps `new GsonJsonMapper()` (library default, JSR-310
aware).
- `GsonTypeMapper(Gson gson)` — wraps `new GsonJsonMapper(gson)`; throws
`NullPointerException` via `Objects.requireNonNull` if `gson` is null.

Methods:

- `public GsonBuilder gsonBuilder()` — returns `delegate.gson().newBuilder()`,
a builder pre-populated with the wrapped `Gson`'s configuration. Lets
callers extend the library default in one line:
`new GsonTypeMapper().gsonBuilder().registerTypeAdapter(...).create()`.
- `readFrom`, `readAs`, `writeTo` — delegate to the internal mapper.

### `OpenApiServer`

No change. The auto-registration class-name constant still points at
`com.retailsvc.http.internal.gson.GsonJsonMapper` and the no-arg
constructor still produces a JSR-310-aware mapper.

### Tests

New `src/test/java/com/retailsvc/http/GsonTypeMapperTest.java`, JUnit 5
with AssertJ, camelCase method names, static imports, curly braces on
every block:

- `noArgConstructorRoundTripsJsr310` — verifies `Instant`,
`OffsetDateTime`, `LocalDate` survive a write→read cycle as ISO-8601.
- `customGsonIsUsed` — supplies a `Gson` with a custom type adapter and
asserts the adapter wins over the default behavior.
- `nullGsonRejected` — constructor with `null` throws `NullPointerException`.
- `gsonBuilderPreservesWrappedConfig` — builds a `GsonTypeMapper` with a
custom Gson, calls `gsonBuilder().create()`, and asserts the custom
adapter still applies on the new instance.

The existing `GsonJsonMapperTest` stays as-is to cover internal behavior.

### Documentation

Update README "JSON mapping" section to add a Gson example alongside the
Jackson ones, showing both `new GsonTypeMapper()` and the
`gsonBuilder()` extension pattern.

## Risks

- Two public ways to obtain a Gson-backed mapper (auto-registration vs
explicit `new GsonTypeMapper()`). Mitigation: README clarifies that
auto-registration is for the zero-config path and `GsonTypeMapper` is
for callers who want to customize.
101 changes: 87 additions & 14 deletions src/main/java/com/retailsvc/http/spec/Spec.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,21 @@
import com.retailsvc.http.spec.security.SecurityScheme;
import com.retailsvc.http.spec.security.SecuritySchemeParser;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
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;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Function;

public record Spec(
String openapi,
Expand Down Expand Up @@ -67,18 +71,18 @@ static Map<String, Object> extractExtensions(Map<String, Object> raw) {
* has an unrecognised extension
*/
public static Spec fromPath(Path path) {
String text;
byte[] bytes;
try {
text = Files.readString(path);
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 = parseJson(text);
raw = parseJsonWithGson(bytes);
} else if (name.endsWith(".yaml") || name.endsWith(".yml")) {
raw = parseYaml(text);
raw = parseYamlWithSnakeYaml(bytes);
} else {
throw new IllegalStateException(
"Unrecognised OpenAPI spec extension for "
Expand All @@ -89,8 +93,75 @@ public static Spec fromPath(Path path) {
return from(raw);
}

private static Map<String, Object> parseJson(String text) {
Class<?> gsonClass = loadOptional(GSON_CLASS, "JSON", "Gson");
/**
* Reads a JSON OpenAPI specification from {@code in} using Gson. Gson must be on the classpath;
* otherwise throws {@link IllegalStateException}. The stream is fully consumed and closed before
* this method returns.
*
* <p>Useful for loading specs from the classpath:
*
* <pre>{@code
* try (InputStream in = getClass().getResourceAsStream("/openapi.json")) {
* Spec spec = Spec.fromJson(in);
* }
* }</pre>
*
* <p>To avoid the Gson dependency (e.g. when using Jackson), use {@link #fromJson(InputStream,
* Function)} instead.
*
* @throws NullPointerException if {@code in} is {@code null}
* @throws UncheckedIOException if the stream cannot be read
* @throws IllegalStateException if Gson is not on the classpath
*/
public static Spec fromJson(InputStream in) {
return fromJson(in, Spec::parseJsonWithGson);
}

/**
* Reads a JSON OpenAPI specification from {@code in} using the supplied {@code parser}. The
* parser receives the full body as bytes and returns the decoded map. The stream is fully
* consumed and closed before this method returns.
*
* <p>Example with Jackson:
*
* <pre>{@code
* ObjectMapper mapper = new ObjectMapper();
* Spec spec = Spec.fromJson(in, bytes -> mapper.readValue(bytes, Map.class));
* }</pre>
*
* @throws NullPointerException if {@code in} or {@code parser} is {@code null}
* @throws UncheckedIOException if the stream cannot be read
*/
public static Spec fromJson(InputStream in, Function<byte[], Map<String, Object>> parser) {
Objects.requireNonNull(parser, "parser");
return from(parser.apply(readAll(in)));
}

/**
* Reads a YAML OpenAPI specification from {@code in} using SnakeYAML. SnakeYAML must be on the
* classpath; otherwise throws {@link IllegalStateException}. The stream is fully consumed and
* closed before this method returns.
*
* @throws NullPointerException if {@code in} is {@code null}
* @throws UncheckedIOException if the stream cannot be read
* @throws IllegalStateException if SnakeYAML is not on the classpath
*/
public static Spec fromYaml(InputStream in) {
return from(parseYamlWithSnakeYaml(readAll(in)));
}

private static byte[] readAll(InputStream in) {
Objects.requireNonNull(in, "in");
try (in) {
return in.readAllBytes();
} catch (IOException e) {
throw new UncheckedIOException("Failed to read OpenAPI spec from stream", e);
}
}

private static Map<String, Object> parseJsonWithGson(byte[] bytes) {
String text = new String(bytes, StandardCharsets.UTF_8);
Class<?> gsonClass = loadOptional(GSON_CLASS, "Json", "Gson");
try {
Object gson = gsonClass.getDeclaredConstructor().newInstance();
Method fromJson = gsonClass.getMethod("fromJson", String.class, Class.class);
Expand All @@ -102,8 +173,9 @@ private static Map<String, Object> parseJson(String text) {
}
}

private static Map<String, Object> parseYaml(String text) {
Class<?> yamlClass = loadOptional(SNAKEYAML_CLASS, "YAML", "SnakeYAML");
private static Map<String, Object> parseYamlWithSnakeYaml(byte[] bytes) {
String text = new String(bytes, StandardCharsets.UTF_8);
Class<?> yamlClass = loadOptional(SNAKEYAML_CLASS, "Yaml", "SnakeYAML");
try {
Object yaml = yamlClass.getDeclaredConstructor().newInstance();
Method load = yamlClass.getMethod("load", String.class);
Expand All @@ -120,14 +192,15 @@ private static Class<?> loadOptional(String className, String format, String lib
return Class.forName(className, false, Spec.class.getClassLoader());
} catch (ClassNotFoundException e) {
throw new IllegalStateException(
"Spec.fromPath requires "
+ libName
+ " on the classpath for "
"Loading "
+ format
+ " specs. Add a "
+ " OpenAPI specs requires "
+ libName
+ " on the classpath. Add a "
+ libName
+ " dependency, or parse the file yourself and call"
+ " Spec.from(Map<String, Object>) instead.",
+ " dependency, or supply your own parser via Spec.from"
+ format
+ "(InputStream, Function) / Spec.from(Map<String, Object>) instead.",
e);
}
}
Expand Down
Loading
Loading