Skip to content

Commit 4188b87

Browse files
committed
open-api: redo javadoc object parsing
- it is more stronger now - cleanup code to new model - added value json like notation for complex description - make the javadoc parsing process die silentely
1 parent 54b0900 commit 4188b87

File tree

7 files changed

+419
-431
lines changed

7 files changed

+419
-431
lines changed
Lines changed: 347 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
1+
/*
2+
* Jooby https://jooby.io
3+
* Apache License Version 2.0 https://jooby.io/LICENSE.txt
4+
* Copyright 2014 Edgar Espina
5+
*/
6+
package io.jooby.internal.openapi.javadoc;
7+
8+
import java.util.ArrayList;
9+
import java.util.LinkedHashMap;
10+
import java.util.List;
11+
import java.util.Map;
12+
13+
/**
14+
* A utility class to parse a list of strings into a nested map structure. It supports multiple data
15+
* rows, dot-separated path expressions, repeated paths, and values explicitly wrapped in square
16+
* brackets '[]'. The structure can be augmented by subsequent rows.
17+
*/
18+
public class JavaDocObjectParser {
19+
20+
/**
21+
* A parser for a non-standard JSON-like syntax where string keys and values are not enclosed in
22+
* quotation marks.
23+
*
24+
* <p>This parser supports: - Key-value pairs (e.g., key:value) - Nested objects (text wrapped
25+
* with {}) - Arrays (text wrapped with []) - All other non-object, non-array values are parsed as
26+
* Strings.
27+
*
28+
* <p>Note: This is a simplified parser. For standard JSON.
29+
*/
30+
public static class UnquotedJsonParser {
31+
32+
private String text;
33+
private int index = 0;
34+
35+
/**
36+
* Parses the given unquoted JSON-like string into a Map.
37+
*
38+
* @param input The string to parse.
39+
* @return A Map representing the parsed structure.
40+
* @throws IllegalArgumentException if the input format is invalid.
41+
*/
42+
public Object parse(String input) {
43+
if (input == null || input.trim().isEmpty()) {
44+
throw new IllegalArgumentException("Input string cannot be null or empty.");
45+
}
46+
this.text = input.trim();
47+
this.index = 0;
48+
if (text.startsWith("{") && text.endsWith("}")) {
49+
return parseObject();
50+
} else if (text.startsWith("[") && text.endsWith("]")) {
51+
return parseArray();
52+
}
53+
return input;
54+
}
55+
56+
/**
57+
* Parses an object structure, starting from the current index. An object is expected to be
58+
* enclosed in '{' and '}'.
59+
*/
60+
private Map<String, Object> parseObject() {
61+
Map<String, Object> map = new LinkedHashMap<>();
62+
63+
expectChar('{');
64+
skipWhitespace();
65+
66+
while (peek() != '}') {
67+
String key = parseKey();
68+
skipWhitespace();
69+
expectChar(':');
70+
skipWhitespace();
71+
Object value = parseValue();
72+
map.put(key, value);
73+
skipWhitespace();
74+
75+
if (peek() == ',') {
76+
consumeChar();
77+
skipWhitespace();
78+
} else if (peek() != '}') {
79+
throw new IllegalArgumentException("Expected ',' or '}' after value at index " + index);
80+
}
81+
}
82+
83+
expectChar('}');
84+
return map;
85+
}
86+
87+
/**
88+
* Parses an array structure, starting from the current index. An array is expected to be
89+
* enclosed in '[' and ']'.
90+
*/
91+
private List<Object> parseArray() {
92+
List<Object> list = new ArrayList<>();
93+
94+
expectChar('[');
95+
skipWhitespace();
96+
97+
while (peek() != ']') {
98+
list.add(parseValue());
99+
skipWhitespace();
100+
101+
if (peek() == ',') {
102+
consumeChar();
103+
skipWhitespace();
104+
} else if (peek() != ']') {
105+
throw new IllegalArgumentException(
106+
"Expected ',' or ']' after value in array at index " + index);
107+
}
108+
}
109+
110+
expectChar(']');
111+
return list;
112+
}
113+
114+
/**
115+
* Parses a key from the input string. A key is a sequence of characters up to the colon ':'.
116+
*/
117+
private String parseKey() {
118+
int start = index;
119+
while (index < text.length() && text.charAt(index) != ':') {
120+
index++;
121+
}
122+
if (index == start) {
123+
throw new IllegalArgumentException("Found empty key at index " + start);
124+
}
125+
return text.substring(start, index).trim();
126+
}
127+
128+
/**
129+
* Determines the type of the value at the current index and parses it. It can be an object, an
130+
* array, or a string literal.
131+
*/
132+
private Object parseValue() {
133+
skipWhitespace();
134+
char currentChar = peek();
135+
136+
if (currentChar == '{') {
137+
return parseObject();
138+
} else if (currentChar == '[') {
139+
return parseArray();
140+
} else {
141+
return parseLiteral();
142+
}
143+
}
144+
145+
/**
146+
* Parses a literal value as a string. The literal ends at the next comma ',', closing brace
147+
* '}', or closing bracket ']'.
148+
*/
149+
private String parseLiteral() {
150+
int start = index;
151+
while (index < text.length()
152+
&& text.charAt(index) != ','
153+
&& text.charAt(index) != '}'
154+
&& text.charAt(index) != ']') {
155+
index++;
156+
}
157+
return text.substring(start, index).trim();
158+
}
159+
160+
// --- Utility Methods ---
161+
162+
private char peek() {
163+
if (index >= text.length()) {
164+
throw new IllegalArgumentException("Unexpected end of input.");
165+
}
166+
return text.charAt(index);
167+
}
168+
169+
private void consumeChar() {
170+
index++;
171+
}
172+
173+
private void expectChar(char expected) {
174+
skipWhitespace();
175+
if (peek() != expected) {
176+
throw new IllegalArgumentException(
177+
"Expected '" + expected + "' but found '" + peek() + "' at index " + index);
178+
}
179+
consumeChar();
180+
}
181+
182+
private void skipWhitespace() {
183+
while (index < text.length() && Character.isWhitespace(text.charAt(index))) {
184+
index++;
185+
}
186+
}
187+
}
188+
189+
/**
190+
* Parses a list containing one or more data rows into a list of maps. - A new row is started each
191+
* time the first key of the input list is encountered. - Subsequent rows can add new keys
192+
* (augment the structure) but cannot change the data type of an existing path (e.g., from an
193+
* object to a string). - Repeated paths within a single row will generate a list of values. - A
194+
* value wrapped in '[]' (e.g., "[value]") will be parsed as a list with a single element.
195+
*
196+
* @param inputList The list of strings to parse. Must not be null.
197+
* @return A List of Maps, where each map represents a data row.
198+
* @throws IllegalArgumentException if a row tries to change an existing data structure.
199+
*/
200+
public static List<Map<String, Object>> parse(List<String> inputList) {
201+
try {
202+
if (inputList.isEmpty()) {
203+
return List.of();
204+
}
205+
206+
// --- Step 1: Split the flat input list into a list of rows ---
207+
List<List<String>> allRowsPairs = new ArrayList<>();
208+
String rowDelimiterKey = inputList.get(0);
209+
List<String> currentRowPairs = new ArrayList<>();
210+
211+
for (int i = 0; i < inputList.size(); i += 2) {
212+
String key = inputList.get(i);
213+
if (i + 1 >= inputList.size()) {
214+
break; // Ignore last key if it has no value
215+
}
216+
String value = inputList.get(i + 1);
217+
218+
if (key.equals(rowDelimiterKey) && !currentRowPairs.isEmpty()) {
219+
allRowsPairs.add(new ArrayList<>(currentRowPairs));
220+
currentRowPairs.clear();
221+
}
222+
currentRowPairs.add(key);
223+
currentRowPairs.add(value);
224+
}
225+
if (!currentRowPairs.isEmpty()) {
226+
allRowsPairs.add(new ArrayList<>(currentRowPairs));
227+
}
228+
229+
// --- Step 2: Process each row independently and add its map to the result list ---
230+
List<Map<String, Object>> resultList = new ArrayList<>();
231+
for (List<String> rowPairs : allRowsPairs) {
232+
resultList.add(buildMapFromPairs(rowPairs));
233+
}
234+
235+
return resultList;
236+
} catch (Exception ignored) {
237+
// just returns empty, we won't fail the entire process.
238+
return List.of();
239+
}
240+
}
241+
242+
/**
243+
* Builds a single nested map from a list of key-value pairs representing one row. This helper
244+
* contains the core parsing logic for a single data row.
245+
*
246+
* @param pairs A list of strings for a single data row.
247+
* @return A single, potentially nested, map.
248+
*/
249+
private static Map<String, Object> buildMapFromPairs(List<String> pairs) {
250+
Map<String, Object> resultMap = new LinkedHashMap<>();
251+
252+
// Step 1: Group all values by their full path.
253+
Map<String, List<String>> pathValues = new LinkedHashMap<>();
254+
for (int i = 0; i < pairs.size(); i += 2) {
255+
pathValues.computeIfAbsent(pairs.get(i), k -> new ArrayList<>()).add(pairs.get(i + 1));
256+
}
257+
258+
// Step 2: Group paths by their first segment (e.g., "parents" from "parents.name").
259+
Map<String, List<String>> groupedByFirstSegment = new LinkedHashMap<>();
260+
for (String path : pathValues.keySet()) {
261+
String firstSegment = path.split("\\.")[0];
262+
groupedByFirstSegment.computeIfAbsent(firstSegment, k -> new ArrayList<>()).add(path);
263+
}
264+
265+
// Step 3: Build the map by processing each group.
266+
for (Map.Entry<String, List<String>> entry : groupedByFirstSegment.entrySet()) {
267+
String groupKey = entry.getKey();
268+
List<String> pathsInGroup = entry.getValue();
269+
270+
boolean isList = pathsInGroup.stream().anyMatch(p -> pathValues.get(p).size() > 1);
271+
boolean isSimpleList = pathsInGroup.size() == 1 && pathsInGroup.get(0).equals(groupKey);
272+
273+
if (isList && isSimpleList) {
274+
// Case 1: A list of simple values (e.g., "tags": ["a", "b"]).
275+
List<String> values = pathValues.get(groupKey);
276+
resultMap.put(groupKey, values);
277+
} else if (isList) {
278+
// Case 2: A list of nested objects (e.g., "parents": [{...}, {...}]).
279+
int rowCount = pathValues.get(pathsInGroup.get(0)).size();
280+
List<Map<String, Object>> listOfMaps = new ArrayList<>();
281+
for (int i = 0; i < rowCount; i++) {
282+
listOfMaps.add(new LinkedHashMap<>());
283+
}
284+
285+
for (String path : pathsInGroup) {
286+
List<String> values = pathValues.get(path);
287+
if (values.size() != rowCount) {
288+
throw new IllegalArgumentException(
289+
"Mismatched value count for properties of '" + groupKey + "'.");
290+
}
291+
String innerPath = path.substring(groupKey.length() + 1);
292+
for (int i = 0; i < rowCount; i++) {
293+
setValue(listOfMaps.get(i), innerPath, values.get(i));
294+
}
295+
}
296+
resultMap.put(groupKey, listOfMaps);
297+
} else {
298+
// Case 3: Simple key-value pairs, possibly nested (e.g., "address.country").
299+
for (String path : pathsInGroup) {
300+
String value = pathValues.get(path).get(0);
301+
setValue(resultMap, path, parseValue(value));
302+
}
303+
}
304+
}
305+
return resultMap;
306+
}
307+
308+
private static Object parseValue(String value) {
309+
try {
310+
return new UnquotedJsonParser().parse(value);
311+
} catch (Exception ignored) {
312+
// just returns the input value, we won't fail the entire process
313+
return value;
314+
}
315+
}
316+
317+
/**
318+
* A helper method to place a value into a nested map structure based on a dot-separated path. It
319+
* creates nested maps as needed and throws an exception on path conflicts.
320+
*
321+
* @param map The root map to modify.
322+
* @param path The dot-separated path (e.g., "address.country").
323+
* @param value The value to set at the specified path.
324+
*/
325+
@SuppressWarnings("unchecked")
326+
private static void setValue(Map<String, Object> map, String path, Object value) {
327+
String[] parts = path.split("\\.");
328+
Map<String, Object> currentMap = map;
329+
330+
for (int i = 0; i < parts.length - 1; i++) {
331+
String part = parts[i];
332+
Object node = currentMap.computeIfAbsent(part, k -> new LinkedHashMap<String, Object>());
333+
334+
if (!(node instanceof Map)) {
335+
throw new IllegalArgumentException(
336+
"Path conflict: '" + part + "' contains a value and cannot be treated as a map.");
337+
}
338+
currentMap = (Map<String, Object>) node;
339+
}
340+
341+
String finalKey = parts[parts.length - 1];
342+
if (!currentMap.containsKey(finalKey)) {
343+
// Don't override
344+
currentMap.put(finalKey, value);
345+
}
346+
}
347+
}

0 commit comments

Comments
 (0)