Skip to content

Commit 630697b

Browse files
committed
feat(spec): Add Spec.from(Map) walker for the full document
1 parent 2491210 commit 630697b

2 files changed

Lines changed: 266 additions & 0 deletions

File tree

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
package com.retailsvc.http.spec;
2+
3+
import com.retailsvc.http.spec.schema.Schema;
4+
import com.retailsvc.http.spec.schema.SchemaParser;
5+
import java.net.URI;
6+
import java.util.ArrayList;
7+
import java.util.LinkedHashMap;
8+
import java.util.List;
9+
import java.util.Locale;
10+
import java.util.Map;
11+
import java.util.Optional;
12+
13+
public record Spec(
14+
String openapi,
15+
Info info,
16+
List<Server> servers,
17+
List<Operation> operations,
18+
Map<String, Schema> componentSchemas,
19+
Map<String, Parameter> componentParameters) {
20+
21+
@SuppressWarnings("unchecked")
22+
public static Spec from(Map<String, Object> raw) {
23+
String openapi = (String) raw.get("openapi");
24+
Info info = parseInfo((Map<String, Object>) raw.get("info"));
25+
List<Server> servers = parseServers((List<Map<String, Object>>) raw.get("servers"));
26+
Map<String, Object> rawComponents =
27+
(Map<String, Object>) raw.getOrDefault("components", Map.of());
28+
Map<String, Schema> componentSchemas = parseComponentSchemas(rawComponents);
29+
Map<String, Parameter> componentParameters = parseComponentParameters(rawComponents);
30+
List<Operation> operations =
31+
parseOperations(
32+
(Map<String, Object>) raw.getOrDefault("paths", Map.of()), componentParameters);
33+
return new Spec(openapi, info, servers, operations, componentSchemas, componentParameters);
34+
}
35+
36+
public String basePath() {
37+
if (servers.isEmpty()) {
38+
throw new IllegalStateException("no servers declared");
39+
}
40+
return Optional.ofNullable(URI.create(servers.get(0).url()).getPath()).orElse("");
41+
}
42+
43+
public Schema resolveSchema(String ref) {
44+
String name = stripPrefix(ref, "#/components/schemas/");
45+
Schema s = componentSchemas.get(name);
46+
if (s == null) throw new IllegalArgumentException("unknown schema ref: " + ref);
47+
return s;
48+
}
49+
50+
public Parameter resolveParameter(String ref) {
51+
String name = stripPrefix(ref, "#/components/parameters/");
52+
Parameter p = componentParameters.get(name);
53+
if (p == null) throw new IllegalArgumentException("unknown parameter ref: " + ref);
54+
return p;
55+
}
56+
57+
private static String stripPrefix(String ref, String prefix) {
58+
if (!ref.startsWith(prefix)) {
59+
throw new IllegalArgumentException("ref does not start with " + prefix + ": " + ref);
60+
}
61+
return ref.substring(prefix.length());
62+
}
63+
64+
private static Info parseInfo(Map<String, Object> raw) {
65+
return new Info((String) raw.get("title"), (String) raw.get("version"));
66+
}
67+
68+
private static List<Server> parseServers(List<Map<String, Object>> raw) {
69+
if (raw == null || raw.isEmpty()) return List.of();
70+
return raw.stream().map(m -> new Server((String) m.get("url"))).toList();
71+
}
72+
73+
@SuppressWarnings("unchecked")
74+
private static Map<String, Schema> parseComponentSchemas(Map<String, Object> rawComponents) {
75+
Map<String, Object> rawSchemas =
76+
(Map<String, Object>) rawComponents.getOrDefault("schemas", Map.of());
77+
Map<String, Schema> out = new LinkedHashMap<>();
78+
for (var e : rawSchemas.entrySet()) {
79+
out.put(e.getKey(), SchemaParser.parse((Map<String, Object>) e.getValue()));
80+
}
81+
return Map.copyOf(out);
82+
}
83+
84+
@SuppressWarnings("unchecked")
85+
private static Map<String, Parameter> parseComponentParameters(
86+
Map<String, Object> rawComponents) {
87+
Map<String, Object> rawParams =
88+
(Map<String, Object>) rawComponents.getOrDefault("parameters", Map.of());
89+
Map<String, Parameter> out = new LinkedHashMap<>();
90+
for (var e : rawParams.entrySet()) {
91+
out.put(e.getKey(), parseParameter((Map<String, Object>) e.getValue()));
92+
}
93+
return Map.copyOf(out);
94+
}
95+
96+
@SuppressWarnings("unchecked")
97+
private static Parameter parseParameter(Map<String, Object> raw) {
98+
return new Parameter(
99+
(String) raw.get("name"),
100+
Parameter.Location.valueOf(((String) raw.get("in")).toUpperCase(Locale.ROOT)),
101+
Boolean.TRUE.equals(raw.get("required")),
102+
SchemaParser.parse(
103+
(Map<String, Object>) raw.getOrDefault("schema", Map.of("type", "string"))));
104+
}
105+
106+
@SuppressWarnings("unchecked")
107+
private static List<Operation> parseOperations(
108+
Map<String, Object> rawPaths, Map<String, Parameter> componentParameters) {
109+
List<Operation> out = new ArrayList<>();
110+
for (var pathEntry : rawPaths.entrySet()) {
111+
PathTemplate template = PathTemplate.compile(pathEntry.getKey());
112+
Map<String, Object> pathItem = (Map<String, Object>) pathEntry.getValue();
113+
for (HttpMethod m : HttpMethod.values()) {
114+
Object opRaw = pathItem.get(m.name().toLowerCase(Locale.ROOT));
115+
if (opRaw instanceof Map<?, ?> opMap) {
116+
out.add(parseOperation(m, template, (Map<String, Object>) opMap, componentParameters));
117+
}
118+
}
119+
}
120+
return List.copyOf(out);
121+
}
122+
123+
@SuppressWarnings("unchecked")
124+
private static Operation parseOperation(
125+
HttpMethod method,
126+
PathTemplate path,
127+
Map<String, Object> raw,
128+
Map<String, Parameter> componentParameters) {
129+
String opId = (String) raw.get("operationId");
130+
Optional<RequestBody> body =
131+
Optional.ofNullable((Map<String, Object>) raw.get("requestBody"))
132+
.map(Spec::parseRequestBody);
133+
List<Parameter> params =
134+
Optional.ofNullable((List<Map<String, Object>>) raw.get("parameters"))
135+
.map(
136+
list ->
137+
list.stream()
138+
.map(p -> resolveParameterOrParse(p, componentParameters))
139+
.toList())
140+
.orElse(List.of());
141+
Map<String, Response> responses =
142+
parseResponses((Map<String, Object>) raw.getOrDefault("responses", Map.of()));
143+
return new Operation(opId, method, path, body, params, responses);
144+
}
145+
146+
@SuppressWarnings("unchecked")
147+
private static Parameter resolveParameterOrParse(
148+
Map<String, Object> raw, Map<String, Parameter> componentParameters) {
149+
String ref = (String) raw.get("$ref");
150+
if (ref != null) {
151+
String name = stripPrefix(ref, "#/components/parameters/");
152+
Parameter p = componentParameters.get(name);
153+
if (p == null) throw new IllegalArgumentException("unknown parameter ref: " + ref);
154+
return p;
155+
}
156+
return parseParameter(raw);
157+
}
158+
159+
@SuppressWarnings("unchecked")
160+
private static RequestBody parseRequestBody(Map<String, Object> raw) {
161+
Map<String, Object> contentRaw = (Map<String, Object>) raw.getOrDefault("content", Map.of());
162+
Map<String, MediaType> content = new LinkedHashMap<>();
163+
for (var e : contentRaw.entrySet()) {
164+
Map<String, Object> mt = (Map<String, Object>) e.getValue();
165+
content.put(
166+
e.getKey(),
167+
new MediaType(
168+
SchemaParser.parse(
169+
(Map<String, Object>) mt.getOrDefault("schema", Map.of("type", "object")))));
170+
}
171+
return new RequestBody(Boolean.TRUE.equals(raw.get("required")), Map.copyOf(content));
172+
}
173+
174+
@SuppressWarnings("unchecked")
175+
private static Map<String, Response> parseResponses(Map<String, Object> raw) {
176+
Map<String, Response> out = new LinkedHashMap<>();
177+
for (var e : raw.entrySet()) {
178+
Map<String, Object> r = (Map<String, Object>) e.getValue();
179+
Map<String, Object> contentRaw = (Map<String, Object>) r.getOrDefault("content", Map.of());
180+
Map<String, MediaType> content = new LinkedHashMap<>();
181+
for (var ce : contentRaw.entrySet()) {
182+
Map<String, Object> mt = (Map<String, Object>) ce.getValue();
183+
if (mt.containsKey("schema")) {
184+
content.put(
185+
ce.getKey(),
186+
new MediaType(SchemaParser.parse((Map<String, Object>) mt.get("schema"))));
187+
}
188+
}
189+
out.put(e.getKey(), new Response(Map.copyOf(content)));
190+
}
191+
return Map.copyOf(out);
192+
}
193+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package com.retailsvc.http.spec;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import com.google.gson.Gson;
6+
import com.retailsvc.http.spec.schema.ObjectSchema;
7+
import java.util.List;
8+
import java.util.Map;
9+
import org.junit.jupiter.api.Test;
10+
11+
class SpecTest {
12+
private final Gson gson = new Gson();
13+
14+
@SuppressWarnings("unchecked")
15+
private Map<String, Object> loadJson(String resource) throws Exception {
16+
String text = new String(SpecTest.class.getResourceAsStream("/" + resource).readAllBytes());
17+
return (Map<String, Object>) gson.fromJson(text, Map.class);
18+
}
19+
20+
@Test
21+
void parsesMinimalSpec() {
22+
Map<String, Object> raw =
23+
Map.of(
24+
"openapi", "3.1.0",
25+
"info", Map.of("title", "x", "version", "1"),
26+
"servers", List.of(Map.of("url", "http://localhost/api")),
27+
"paths", Map.of());
28+
Spec spec = Spec.from(raw);
29+
assertThat(spec.openapi()).isEqualTo("3.1.0");
30+
assertThat(spec.info().title()).isEqualTo("x");
31+
assertThat(spec.servers()).hasSize(1);
32+
assertThat(spec.basePath()).isEqualTo("/api");
33+
assertThat(spec.operations()).isEmpty();
34+
}
35+
36+
@Test
37+
void parsesPathsWithMethods() {
38+
Map<String, Object> raw =
39+
Map.of(
40+
"openapi", "3.1.0",
41+
"info", Map.of("title", "x", "version", "1"),
42+
"servers", List.of(Map.of("url", "http://localhost")),
43+
"paths",
44+
Map.of(
45+
"/users",
46+
Map.of(
47+
"get", Map.of("operationId", "list", "responses", Map.of()),
48+
"post", Map.of("operationId", "create", "responses", Map.of()))));
49+
Spec spec = Spec.from(raw);
50+
assertThat(spec.operations()).hasSize(2);
51+
assertThat(spec.operations().stream().map(Operation::operationId))
52+
.containsExactlyInAnyOrder("list", "create");
53+
}
54+
55+
@Test
56+
void resolvesSchemaRef() {
57+
Map<String, Object> raw =
58+
Map.of(
59+
"openapi", "3.1.0",
60+
"info", Map.of("title", "x", "version", "1"),
61+
"servers", List.of(Map.of("url", "/")),
62+
"paths", Map.of(),
63+
"components", Map.of("schemas", Map.of("User", Map.of("type", "object"))));
64+
Spec spec = Spec.from(raw);
65+
assertThat(spec.resolveSchema("#/components/schemas/User")).isInstanceOf(ObjectSchema.class);
66+
}
67+
68+
@Test
69+
void parsesExistingFixture() throws Exception {
70+
Spec spec = Spec.from(loadJson("openapi.json"));
71+
assertThat(spec.operations()).isNotEmpty();
72+
}
73+
}

0 commit comments

Comments
 (0)