Skip to content

Commit 3b8f989

Browse files
committed
feat: Add Spec.fromPath(Path) with JSON+YAML auto-detect
1 parent c4a9009 commit 3b8f989

3 files changed

Lines changed: 133 additions & 12 deletions

File tree

README.md

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,8 @@ A `null` body always produces a status-only response (`Content-Length: 0`, no bo
8585
``` java
8686
public class YourServerLauncher {
8787
public static void main(String[] args) throws Exception {
88-
Gson gson = new Gson();
89-
90-
// Parse spec to a generic Map (works for JSON; for YAML use SnakeYAML).
91-
String text = Files.readString(Path.of("openapi.json"));
92-
Map<String, Object> raw = (Map<String, Object>) gson.fromJson(text, Map.class);
93-
Spec spec = Spec.from(raw);
88+
// Gson is on the classpath, so we can load the spec in one line.
89+
Spec spec = Spec.fromPath(Path.of("openapi.json"));
9490

9591
// Handlers by operationId.
9692
Map<String, RequestHandler> handlers = new HashMap<>();
@@ -106,12 +102,7 @@ public class YourServerLauncher {
106102
}
107103
```
108104

109-
### YAML specifications
110-
For YAML, replace the JSON parsing line with SnakeYAML:
111-
``` java
112-
Map<String, Object> raw = new Yaml().load(Files.newInputStream(Path.of("openapi.yaml")));
113-
```
114-
The rest is identical.
105+
`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.
115106

116107
### JSON mapping
117108

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

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22

33
import com.retailsvc.http.spec.schema.Schema;
44
import com.retailsvc.http.spec.schema.SchemaParser;
5+
import java.io.IOException;
6+
import java.io.UncheckedIOException;
7+
import java.lang.reflect.Method;
58
import java.net.URI;
9+
import java.nio.file.Files;
10+
import java.nio.file.Path;
611
import java.util.ArrayList;
712
import java.util.LinkedHashMap;
813
import java.util.List;
@@ -36,6 +41,91 @@ static Map<String, Object> extractExtensions(Map<String, Object> raw) {
3641
return Map.copyOf(out);
3742
}
3843

44+
private static final String GSON_CLASS = "com.google.gson.Gson";
45+
private static final String SNAKEYAML_CLASS = "org.yaml.snakeyaml.Yaml";
46+
47+
/**
48+
* Reads an OpenAPI specification from {@code path}. Picks the parser by file extension:
49+
*
50+
* <ul>
51+
* <li>{@code .json} → Gson must be on the classpath.
52+
* <li>{@code .yaml} or {@code .yml} → SnakeYAML must be on the classpath.
53+
* </ul>
54+
*
55+
* <p>Both Gson and SnakeYAML are optional dependencies of this library. If the parser for the
56+
* file's extension is not present, throws {@link IllegalStateException} — register your own
57+
* parser and call {@link #from(Map)} instead.
58+
*
59+
* @throws UncheckedIOException if the file cannot be read
60+
* @throws IllegalStateException if the required parser is not on the classpath, or if the file
61+
* has an unrecognised extension
62+
*/
63+
public static Spec fromPath(Path path) {
64+
String text;
65+
try {
66+
text = Files.readString(path);
67+
} catch (IOException e) {
68+
throw new UncheckedIOException("Failed to read OpenAPI spec from " + path, e);
69+
}
70+
String name = path.getFileName().toString().toLowerCase(Locale.ROOT);
71+
Map<String, Object> raw;
72+
if (name.endsWith(".json")) {
73+
raw = parseJson(text);
74+
} else if (name.endsWith(".yaml") || name.endsWith(".yml")) {
75+
raw = parseYaml(text);
76+
} else {
77+
throw new IllegalStateException(
78+
"Unrecognised OpenAPI spec extension for "
79+
+ path
80+
+ " — expected .json, .yaml, or .yml. Parse the file yourself and call"
81+
+ " Spec.from(Map<String, Object>) instead.");
82+
}
83+
return from(raw);
84+
}
85+
86+
private static Map<String, Object> parseJson(String text) {
87+
Class<?> gsonClass = loadOptional(GSON_CLASS, "JSON", "Gson");
88+
try {
89+
Object gson = gsonClass.getDeclaredConstructor().newInstance();
90+
Method fromJson = gsonClass.getMethod("fromJson", String.class, Class.class);
91+
@SuppressWarnings("unchecked")
92+
Map<String, Object> raw = (Map<String, Object>) fromJson.invoke(gson, text, Map.class);
93+
return raw;
94+
} catch (ReflectiveOperationException e) {
95+
throw new IllegalStateException("Failed to parse OpenAPI spec via Gson", e);
96+
}
97+
}
98+
99+
private static Map<String, Object> parseYaml(String text) {
100+
Class<?> yamlClass = loadOptional(SNAKEYAML_CLASS, "YAML", "SnakeYAML");
101+
try {
102+
Object yaml = yamlClass.getDeclaredConstructor().newInstance();
103+
Method load = yamlClass.getMethod("load", String.class);
104+
@SuppressWarnings("unchecked")
105+
Map<String, Object> raw = (Map<String, Object>) load.invoke(yaml, text);
106+
return raw;
107+
} catch (ReflectiveOperationException e) {
108+
throw new IllegalStateException("Failed to parse OpenAPI spec via SnakeYAML", e);
109+
}
110+
}
111+
112+
private static Class<?> loadOptional(String className, String format, String libName) {
113+
try {
114+
return Class.forName(className, false, Spec.class.getClassLoader());
115+
} catch (ClassNotFoundException e) {
116+
throw new IllegalStateException(
117+
"Spec.fromPath requires "
118+
+ libName
119+
+ " on the classpath for "
120+
+ format
121+
+ " specs. Add a "
122+
+ libName
123+
+ " dependency, or parse the file yourself and call"
124+
+ " Spec.from(Map<String, Object>) instead.",
125+
e);
126+
}
127+
}
128+
39129
@SuppressWarnings("unchecked")
40130
public static Spec from(Map<String, Object> raw) {
41131
String openapi = (String) raw.get("openapi");
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package com.retailsvc.http.spec;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import java.nio.file.Path;
6+
import org.junit.jupiter.api.Test;
7+
8+
class SpecFromPathTest {
9+
10+
@Test
11+
void loadsJsonSpecViaGson() throws Exception {
12+
Path resource = Path.of(getClass().getResource("/openapi.json").toURI());
13+
14+
Spec spec = Spec.fromPath(resource);
15+
16+
assertThat(spec.openapi()).startsWith("3.1");
17+
assertThat(spec.basePath()).isEqualTo("/api/v1");
18+
assertThat(spec.operations()).isNotEmpty();
19+
}
20+
21+
@Test
22+
void loadsYamlSpecViaSnakeYaml() throws Exception {
23+
Path resource = Path.of(getClass().getResource("/openapi.yaml").toURI());
24+
25+
Spec spec = Spec.fromPath(resource);
26+
27+
assertThat(spec.openapi()).startsWith("3.1");
28+
assertThat(spec.operations()).isNotEmpty();
29+
}
30+
31+
@Test
32+
void rejectsUnknownExtension(@org.junit.jupiter.api.io.TempDir Path tmp) throws Exception {
33+
Path unknown = tmp.resolve("spec.txt");
34+
java.nio.file.Files.writeString(unknown, "{}");
35+
36+
org.assertj.core.api.Assertions.assertThatThrownBy(() -> Spec.fromPath(unknown))
37+
.isInstanceOf(IllegalStateException.class)
38+
.hasMessageContaining("Unrecognised OpenAPI spec extension");
39+
}
40+
}

0 commit comments

Comments
 (0)