Skip to content

Commit 826c011

Browse files
committed
open-api: parse open-api extensions
- it uses the java doc tag: `@x-extension-name value`
1 parent 8c62850 commit 826c011

File tree

11 files changed

+285
-11
lines changed

11 files changed

+285
-11
lines changed

modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/AnnotationParser.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,9 @@ public static List<OperationExt> parse(ParserContext ctx, String prefix, Type ty
279279
doc -> {
280280
operationExt.setPathDescription(doc.getDescription());
281281
operationExt.setPathSummary(doc.getSummary());
282+
if (!doc.getExtensions().isEmpty()) {
283+
operationExt.setPathExtensions(doc.getExtensions());
284+
}
282285
var parameterNames =
283286
Optional.ofNullable(operationExt.getNode().parameters)
284287
.orElse(List.of())
@@ -290,6 +293,9 @@ public static List<OperationExt> parse(ParserContext ctx, String prefix, Type ty
290293
methodDoc -> {
291294
operationExt.setSummary(methodDoc.getSummary());
292295
operationExt.setDescription(methodDoc.getDescription());
296+
if (!methodDoc.getExtensions().isEmpty()) {
297+
operationExt.setExtensions(methodDoc.getExtensions());
298+
}
293299
// Parameters
294300
for (var parameterName : parameterNames) {
295301
var paramExt =

modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/OperationExt.java

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,7 @@
88
import static io.jooby.internal.openapi.StatusCodeParser.isSuccessCode;
99
import static java.util.Optional.ofNullable;
1010

11-
import java.util.ArrayList;
12-
import java.util.Collections;
13-
import java.util.LinkedList;
14-
import java.util.List;
15-
import java.util.Objects;
16-
import java.util.Optional;
11+
import java.util.*;
1712
import java.util.stream.Collectors;
1813
import java.util.stream.Stream;
1914

@@ -40,6 +35,7 @@ public class OperationExt extends io.swagger.v3.oas.models.Operation {
4035
@JsonIgnore private List<String> responseCodes = new ArrayList<>();
4136
@JsonIgnore private String pathSummary;
4237
@JsonIgnore private String pathDescription;
38+
@JsonIgnore private Map<String, Object> pathExtensions;
4339
@JsonIgnore private List<Tag> globalTags = new ArrayList<>();
4440
@JsonIgnore private ClassNode application;
4541
@JsonIgnore private ClassNode controller;
@@ -172,6 +168,14 @@ public void setPathSummary(String pathSummary) {
172168
this.pathSummary = pathSummary;
173169
}
174170

171+
public Map<String, Object> getPathExtensions() {
172+
return pathExtensions;
173+
}
174+
175+
public void setPathExtensions(Map<String, Object> pathExtensions) {
176+
this.pathExtensions = pathExtensions;
177+
}
178+
175179
public void addTag(Tag tag) {
176180
this.globalTags.add(tag);
177181
addTagsItem(tag.getName());
@@ -224,7 +228,7 @@ public OperationExt copy(String pattern) {
224228
copy.setTags(getTags());
225229
copy.setResponses(getResponses());
226230

227-
/** Redo path keys: */
231+
/* Redo path keys: */
228232
List<String> keys = Router.pathKeys(pattern);
229233
List<Parameter> newParameters = new ArrayList<>();
230234
List<Parameter> parameters = getParameters();
@@ -247,12 +251,15 @@ public OperationExt copy(String pattern) {
247251
copy.setServers(getServers());
248252
copy.setCallbacks(getCallbacks());
249253
copy.setExternalDocs(getExternalDocs());
254+
copy.setExtensions(getExtensions());
250255
copy.setSecurity(getSecurity());
251256
copy.setPathDescription(getPathDescription());
252257
copy.setPathSummary(getPathSummary());
253258
copy.setGlobalTags(getGlobalTags());
254259
copy.setApplication(getApplication());
255260
copy.setController(getController());
261+
copy.setPathDescription(getPathDescription());
262+
copy.setPathExtensions(getPathExtensions());
256263
return copy;
257264
}
258265
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
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+
public class ExtensionJavaDocParser {
14+
@SuppressWarnings("unchecked")
15+
public static Map<String, Object> parse(List<String> properties) {
16+
// The root of our final tree structure.
17+
var root = new LinkedHashMap<String, Object>();
18+
19+
for (int i = 0; i < properties.size(); i += 2) {
20+
var keyPath = properties.get(i);
21+
var value = properties.get(i + 1);
22+
var keys = keyPath.split("\\.");
23+
24+
Map<String, Object> currentNode = root;
25+
for (int j = 0; j < keys.length - 1; j++) {
26+
String key = keys[j];
27+
Object nextNode =
28+
currentNode.computeIfAbsent(key, k -> new LinkedHashMap<String, Object>());
29+
currentNode = (Map<String, Object>) nextNode;
30+
}
31+
var finalKey = keys[keys.length - 1];
32+
@SuppressWarnings("unchecked")
33+
List<String> values =
34+
(List<String>) currentNode.computeIfAbsent(finalKey, k -> new ArrayList<String>());
35+
values.add(value);
36+
}
37+
return (Map<String, Object>) restructureNode(root);
38+
}
39+
40+
/**
41+
* Recursively traverses the tree and restructures nodes where appropriate. If a map contains only
42+
* list-of-string values of the same size, it "zips" them into a list of maps (objects).
43+
*
44+
* @param node The current node (Map or List) to process.
45+
* @return The restructured node.
46+
*/
47+
@SuppressWarnings("unchecked")
48+
private static Object restructureNode(Object node) {
49+
if (!(node instanceof Map)) {
50+
// This is a leaf (already a List<String>), so return it as is.
51+
return node;
52+
}
53+
54+
Map<String, Object> map = (Map<String, Object>) node;
55+
Map<String, Object> restructuredMap = new LinkedHashMap<>();
56+
57+
// First, recursively restructure all children of the current map.
58+
for (Map.Entry<String, Object> entry : map.entrySet()) {
59+
var value = restructureNode(entry.getValue());
60+
var propertyKey = entry.getKey();
61+
restructuredMap.put(propertyKey, value);
62+
}
63+
64+
// Now, check if the current node itself should be restructured.
65+
if (restructuredMap.isEmpty()) {
66+
return restructuredMap;
67+
}
68+
69+
// Check if all values in the map are lists of strings.
70+
boolean canBeZipped = true;
71+
int listSize = -1;
72+
73+
for (var value : restructuredMap.values()) {
74+
if (!(value instanceof List)
75+
|| ((List<?>) value).isEmpty()
76+
|| !(((List<?>) value).getFirst() instanceof String)) {
77+
canBeZipped = false;
78+
break;
79+
}
80+
List<String> list = (List<String>) value;
81+
if (listSize == -1) {
82+
listSize = list.size();
83+
} else if (listSize != list.size()) {
84+
// If lists have different sizes, they can't be zipped together.
85+
canBeZipped = false;
86+
break;
87+
}
88+
}
89+
90+
// If the conditions are met, perform the "zip" operation.
91+
if (canBeZipped) {
92+
List<Map<String, String>> listOfObjects = new ArrayList<>();
93+
for (int i = 0; i < listSize; i++) {
94+
Map<String, String> objectMap = new LinkedHashMap<>();
95+
for (Map.Entry<String, Object> entry : restructuredMap.entrySet()) {
96+
objectMap.put(nameNoDash(entry.getKey()), ((List<String>) entry.getValue()).get(i));
97+
}
98+
listOfObjects.add(objectMap);
99+
}
100+
if (listOfObjects.size() == 1
101+
&& restructuredMap.keySet().stream().noneMatch(ExtensionJavaDocParser::startsWithDash)) {
102+
return listOfObjects.getFirst();
103+
}
104+
return listOfObjects;
105+
}
106+
return restructuredMap;
107+
}
108+
109+
private static boolean startsWithDash(String name) {
110+
return name.charAt(0) == '-';
111+
}
112+
113+
private static String nameNoDash(String name) {
114+
return startsWithDash(name) ? name.substring(1) : name;
115+
}
116+
}

modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocNode.java

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,19 @@
55
*/
66
package io.jooby.internal.openapi.javadoc;
77

8-
import static io.jooby.internal.openapi.javadoc.JavaDocSupport.forward;
8+
import static io.jooby.internal.openapi.javadoc.JavaDocSupport.*;
99

10-
import java.util.List;
10+
import java.util.*;
1111
import java.util.function.Predicate;
1212

13+
import com.fasterxml.jackson.core.JsonProcessingException;
1314
import com.puppycrawl.tools.checkstyle.DetailNodeTreeStringPrinter;
1415
import com.puppycrawl.tools.checkstyle.JavadocDetailNodeParser;
1516
import com.puppycrawl.tools.checkstyle.api.DetailAST;
1617
import com.puppycrawl.tools.checkstyle.api.DetailNode;
1718
import com.puppycrawl.tools.checkstyle.api.JavadocTokenTypes;
1819
import com.puppycrawl.tools.checkstyle.utils.JavadocUtil;
20+
import io.swagger.util.Yaml;
1921

2022
public class JavaDocNode {
2123
private static final Predicate<DetailNode> JAVADOC_TAG =
@@ -24,6 +26,7 @@ public class JavaDocNode {
2426
protected final JavaDocParser context;
2527
protected final DetailAST node;
2628
protected final DetailNode javadoc;
29+
private final Map<String, Object> extensions;
2730

2831
public JavaDocNode(JavaDocParser ctx, DetailAST node, DetailAST comment) {
2932
this(ctx, node, toJavaDocNode(comment));
@@ -33,6 +36,37 @@ protected JavaDocNode(JavaDocParser ctx, DetailAST node, DetailNode javadoc) {
3336
this.context = ctx;
3437
this.node = node;
3538
this.javadoc = javadoc;
39+
if (this.javadoc != EMPTY_NODE) {
40+
this.extensions = parseExtensions(this.javadoc);
41+
} else {
42+
this.extensions = Map.of();
43+
}
44+
}
45+
46+
private Map<String, Object> parseExtensions(DetailNode node) {
47+
var values = new ArrayList<String>();
48+
for (var tag : tree(node).filter(javadocToken(JavadocTokenTypes.JAVADOC_TAG)).toList()) {
49+
var extension =
50+
tree(tag)
51+
.filter(
52+
javadocToken(JavadocTokenTypes.CUSTOM_NAME)
53+
.and(it -> it.getText().startsWith("@x-")))
54+
.findFirst()
55+
.map(DetailNode::getText)
56+
.orElse(null);
57+
if (extension != null) {
58+
extension = extension.substring(1).trim();
59+
var extensionValue =
60+
tree(tag)
61+
.filter(javadocToken(JavadocTokenTypes.DESCRIPTION))
62+
.findFirst()
63+
.map(it -> getText(List.of(it.getChildren()), false))
64+
.orElse(null);
65+
values.add(extension);
66+
values.add(extensionValue);
67+
}
68+
}
69+
return ExtensionJavaDocParser.parse(values);
3670
}
3771

3872
static DetailNode toJavaDocNode(DetailAST node) {
@@ -41,6 +75,10 @@ static DetailNode toJavaDocNode(DetailAST node) {
4175
: new JavadocDetailNodeParser().parseJavadocAsDetailNode(node).getTree();
4276
}
4377

78+
public Map<String, Object> getExtensions() {
79+
return extensions;
80+
}
81+
4482
public String getSummary() {
4583
var builder = new StringBuilder();
4684
for (var node : forward(javadoc, JAVADOC_TAG).toList()) {
@@ -219,4 +257,16 @@ public boolean hasChildren() {
219257
return false;
220258
}
221259
};
260+
261+
public static void main(String[] args) throws JsonProcessingException {
262+
var badges =
263+
Yaml.mapper()
264+
.readValue(
265+
"x-badges:\n"
266+
+ " - name: 'Beta'\n"
267+
+ " position: before\n"
268+
+ " color: purple",
269+
Map.class);
270+
System.out.println(badges);
271+
}
222272
}

modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,9 @@ public String toString(OpenAPIGenerator tool, OpenAPI result) {
164164
Optional.ofNullable(doc.getSummary()).ifPresent(info::setTitle);
165165
Optional.ofNullable(doc.getDescription()).ifPresent(info::setDescription);
166166
Optional.ofNullable(doc.getVersion()).ifPresent(info::setVersion);
167+
if (!doc.getExtensions().isEmpty()) {
168+
info.setExtensions(doc.getExtensions());
169+
}
167170
});
168171
}
169172

@@ -215,6 +218,7 @@ public String toString(OpenAPIGenerator tool, OpenAPI result) {
215218
pathItem.operation(PathItem.HttpMethod.valueOf(operation.getMethod()), operation);
216219
Optional.ofNullable(operation.getPathSummary()).ifPresent(pathItem::setSummary);
217220
Optional.ofNullable(operation.getPathDescription()).ifPresent(pathItem::setDescription);
221+
Optional.ofNullable(operation.getPathExtensions()).ifPresent(pathItem::setExtensions);
218222

219223
// global tags
220224
operation.getGlobalTags().forEach(tag -> globalTags.put(tag.getName(), tag));

modules/jooby-openapi/src/test/java/issues/i3729/api/ApiDocTest.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ public void shouldGenerateDoc(OpenAPIResult result) {
2020
+ " title: Library API.\n"
2121
+ " description: \"Available data: Books and authors.\"\n"
2222
+ " version: 4.0.0\n"
23+
+ " x-logo:\n"
24+
+ " url: https://redocly.github.io/redoc/museum-logo.png\n"
25+
+ " altText: Museum logo\n"
2326
+ "paths:\n"
2427
+ " /api/library/{isbn}:\n"
2528
+ " summary: Library API.\n"
@@ -78,6 +81,10 @@ public void shouldGenerateDoc(OpenAPIResult result) {
7881
+ " type: array\n"
7982
+ " items:\n"
8083
+ " $ref: \"#/components/schemas/Book\"\n"
84+
+ " x-badges:\n"
85+
+ " - name: Beta\n"
86+
+ " position: before\n"
87+
+ " color: purple\n"
8188
+ " post:\n"
8289
+ " summary: Creates a new book.\n"
8390
+ " description: Book can be created or updated.\n"

modules/jooby-openapi/src/test/java/issues/i3729/api/AppLibrary.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
* <p>Available data: Books and authors.
1616
*
1717
* @version 4.0.0
18+
* @x-logo.url https://redocly.github.io/redoc/museum-logo.png
19+
* @x-logo.altText Museum logo
1820
*/
1921
public class AppLibrary extends Jooby {
2022

modules/jooby-openapi/src/test/java/issues/i3729/api/LibraryApi.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ public Book bookByIsbn(@PathParam String isbn) throws NotFoundException, BadRequ
3737
*
3838
* @param query Book's param query.
3939
* @return Matching books.
40+
* @x-badges.-name Beta
41+
* @x-badges.position before
42+
* @x-badges.color purple
4043
*/
4144
@GET
4245
public List<Book> query(@QueryParam BookQuery query) {

0 commit comments

Comments
 (0)