diff --git a/.gitignore b/.gitignore index bac2093..240e877 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ build/ ### Claude Code per-developer settings ### .claude/settings.local.json +.claude/worktrees/ ### Performance recordings ### perf/ diff --git a/README.md b/README.md index a5520f8..8cf322b 100644 --- a/README.md +++ b/README.md @@ -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)` 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 +} +``` + +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)`. + ### 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: diff --git a/docs/superpowers/specs/2026-05-20-public-gson-type-mapper-design.md b/docs/superpowers/specs/2026-05-20-public-gson-type-mapper-design.md new file mode 100644 index 0000000..348f421 --- /dev/null +++ b/docs/superpowers/specs/2026-05-20-public-gson-type-mapper-design.md @@ -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. diff --git a/src/main/java/com/retailsvc/http/spec/Spec.java b/src/main/java/com/retailsvc/http/spec/Spec.java index c3b68eb..00bb982 100644 --- a/src/main/java/com/retailsvc/http/spec/Spec.java +++ b/src/main/java/com/retailsvc/http/spec/Spec.java @@ -6,9 +6,11 @@ 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; @@ -16,7 +18,9 @@ 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, @@ -67,18 +71,18 @@ static Map extractExtensions(Map 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 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 " @@ -89,8 +93,75 @@ public static Spec fromPath(Path path) { return from(raw); } - private static Map 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. + * + *

Useful for loading specs from the classpath: + * + *

{@code
+   * try (InputStream in = getClass().getResourceAsStream("/openapi.json")) {
+   *   Spec spec = Spec.fromJson(in);
+   * }
+   * }
+ * + *

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. + * + *

Example with Jackson: + * + *

{@code
+   * ObjectMapper mapper = new ObjectMapper();
+   * Spec spec = Spec.fromJson(in, bytes -> mapper.readValue(bytes, Map.class));
+   * }
+ * + * @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> 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 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); @@ -102,8 +173,9 @@ private static Map parseJson(String text) { } } - private static Map parseYaml(String text) { - Class yamlClass = loadOptional(SNAKEYAML_CLASS, "YAML", "SnakeYAML"); + private static Map 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); @@ -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) instead.", + + " dependency, or supply your own parser via Spec.from" + + format + + "(InputStream, Function) / Spec.from(Map) instead.", e); } } diff --git a/src/test/java/com/retailsvc/http/spec/SpecFromInputStreamTest.java b/src/test/java/com/retailsvc/http/spec/SpecFromInputStreamTest.java new file mode 100644 index 0000000..0352852 --- /dev/null +++ b/src/test/java/com/retailsvc/http/spec/SpecFromInputStreamTest.java @@ -0,0 +1,162 @@ +package com.retailsvc.http.spec; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.google.gson.Gson; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Function; +import org.junit.jupiter.api.Test; + +class SpecFromInputStreamTest { + + @Test + void fromJsonLoadsClasspathStreamUsingGson() throws Exception { + try (InputStream in = getClass().getResourceAsStream("/openapi.json")) { + Spec spec = Spec.fromJson(in); + + assertThat(spec.openapi()).startsWith("3.1"); + assertThat(spec.basePath()).isEqualTo("/api/v1"); + assertThat(spec.operations()).isNotEmpty(); + } + } + + @Test + void fromYamlLoadsClasspathStreamUsingSnakeYaml() throws Exception { + try (InputStream in = getClass().getResourceAsStream("/openapi.yaml")) { + Spec spec = Spec.fromYaml(in); + + assertThat(spec.openapi()).startsWith("3.1"); + assertThat(spec.operations()).isNotEmpty(); + } + } + + @Test + void fromJsonWithCustomParserDoesNotRequireGson() throws Exception { + Gson gson = new Gson(); + Function> parser = + bytes -> gson.fromJson(new String(bytes, StandardCharsets.UTF_8), Map.class); + + try (InputStream in = getClass().getResourceAsStream("/openapi.json")) { + Spec spec = Spec.fromJson(in, parser); + + assertThat(spec.openapi()).startsWith("3.1"); + } + } + + @Test + void fromJsonClosesStream() throws Exception { + AtomicBoolean closed = new AtomicBoolean(false); + try (InputStream raw = getClass().getResourceAsStream("/openapi.json")) { + InputStream tracking = closingTracker(raw, closed); + + Spec.fromJson(tracking); + + assertThat(closed).isTrue(); + } + } + + @Test + void fromYamlClosesStream() throws Exception { + AtomicBoolean closed = new AtomicBoolean(false); + try (InputStream raw = getClass().getResourceAsStream("/openapi.yaml")) { + InputStream tracking = closingTracker(raw, closed); + + Spec.fromYaml(tracking); + + assertThat(closed).isTrue(); + } + } + + @Test + void fromJsonWithParserClosesStream() { + AtomicBoolean closed = new AtomicBoolean(false); + String json = + "{\"openapi\":\"3.1.0\",\"info\":{\"title\":\"t\",\"version\":\"1\"}," + + "\"servers\":[{\"url\":\"http://localhost/x\"}],\"paths\":{}}"; + InputStream tracking = + closingTracker(new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)), closed); + Gson gson = new Gson(); + Function> parser = + bytes -> gson.fromJson(new String(bytes, StandardCharsets.UTF_8), Map.class); + + Spec.fromJson(tracking, parser); + + assertThat(closed).isTrue(); + } + + @Test + void fromJsonRejectsNullStream() { + assertThatThrownBy(() -> Spec.fromJson((InputStream) null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void fromYamlRejectsNullStream() { + assertThatThrownBy(() -> Spec.fromYaml((InputStream) null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void fromJsonWithParserRejectsNullArgs() { + InputStream in = new ByteArrayInputStream(new byte[0]); + Function> parser = bytes -> Map.of(); + + assertThatThrownBy(() -> Spec.fromJson(null, parser)).isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> Spec.fromJson(in, null)).isInstanceOf(NullPointerException.class); + } + + @Test + void fromJsonPropagatesIoFailure() { + InputStream broken = brokenStream(); + + assertThatThrownBy(() -> Spec.fromJson(broken)).isInstanceOf(UncheckedIOException.class); + } + + @Test + void fromYamlPropagatesIoFailure() { + InputStream broken = brokenStream(); + + assertThatThrownBy(() -> Spec.fromYaml(broken)).isInstanceOf(UncheckedIOException.class); + } + + private static InputStream closingTracker(InputStream delegate, AtomicBoolean flag) { + return new InputStream() { + @Override + public int read() throws IOException { + return delegate.read(); + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + return delegate.read(b, off, len); + } + + @Override + public void close() throws IOException { + flag.set(true); + delegate.close(); + } + }; + } + + private static InputStream brokenStream() { + return new InputStream() { + @Override + public int read() throws IOException { + throw new IOException("boom"); + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + throw new IOException("boom"); + } + }; + } +}