Skip to content

Commit d7c7946

Browse files
committed
- implement @Project/projection = "" for OpenAPI
- ref #3853
1 parent bf21582 commit d7c7946

14 files changed

Lines changed: 819 additions & 17 deletions

File tree

jooby/src/main/java/io/jooby/Projection.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
import java.util.concurrent.ConcurrentHashMap;
1616
import java.util.function.Consumer;
1717

18+
import io.jooby.value.ValueFactory;
19+
1820
/**
1921
* Hierarchical schema for JSON field selection. A Projection defines exactly which fields of a Java
2022
* object should be serialized to JSON.
@@ -203,6 +205,12 @@ public Projection<T> validate() {
203205
return this;
204206
}
205207

208+
/** Determines if a type is a simple/scalar value that cannot be further projected. */
209+
private boolean isSimpleType(Type type) {
210+
var valueFactory = new ValueFactory();
211+
return valueFactory.get(type) != null;
212+
}
213+
206214
/**
207215
* Returns the Avaje-compatible DSL string.
208216
*

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

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@
55
*/
66
package io.jooby.internal.openapi;
77

8-
import java.io.FileNotFoundException;
9-
import java.io.IOException;
108
import java.io.InputStream;
119

1210
import io.jooby.SneakyThrows;
@@ -33,13 +31,4 @@ public byte[] loadClass(String classname) {
3331
throw SneakyThrows.propagate(x);
3432
}
3533
}
36-
37-
public byte[] loadResource(String path) throws IOException {
38-
try (InputStream stream = classLoader.getResourceAsStream(path)) {
39-
if (stream == null) {
40-
throw new FileNotFoundException(path);
41-
}
42-
return stream.readAllBytes();
43-
}
44-
}
4534
}

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,23 @@ public List<AnnotationNode> getAllAnnotations() {
232232
.collect(Collectors.toList());
233233
}
234234

235+
@JsonIgnore
236+
public String getProjection() {
237+
return getAllAnnotations().stream()
238+
.filter(
239+
it ->
240+
Router.METHODS.stream()
241+
.map(method -> "Lio/jooby/annotation/" + method + ";")
242+
.anyMatch(it.desc::equals)
243+
&& it.values != null)
244+
.map(it -> AnnotationUtils.findAnnotationValue(it, "projection"))
245+
.filter(Optional::isPresent)
246+
.map(Optional::get)
247+
.findFirst()
248+
.map(Object::toString)
249+
.orElse(null);
250+
}
251+
235252
public OperationExt copy(String pattern) {
236253
OperationExt copy = new OperationExt(node, method, pattern, getParameters(), defaultResponse);
237254
copy.setTags(getTags());

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

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
import static java.util.Arrays.asList;
1313

1414
import java.io.File;
15-
import java.io.IOException;
1615
import java.io.InputStream;
1716
import java.io.PrintWriter;
1817
import java.io.Reader;
@@ -427,10 +426,14 @@ public Schema schema(String type) {
427426
if (schema != null) {
428427
return schema.toSchema();
429428
}
429+
return schema(javaType(type));
430+
}
431+
432+
public JavaType javaType(String type) {
430433
String json = "{\"type\":\"" + type + "\"}";
431434
try {
432435
TypeLiteral literal = json().readValue(json, TypeLiteral.class);
433-
return schema(literal.type);
436+
return literal.type;
434437
} catch (Exception x) {
435438
throw SneakyThrows.propagate(x);
436439
}
@@ -501,10 +504,6 @@ public ClassNode classNodeOrNull(Type type) {
501504
}
502505
}
503506

504-
public byte[] loadResource(String path) throws IOException {
505-
return source.loadResource(path);
506-
}
507-
508507
private ClassNode newClassNode(Type type) {
509508
ClassReader reader = new ClassReader(source.loadClass(type.getClassName()));
510509
if (debug.contains(DebugOption.ALL)) {
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
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.projection;
7+
8+
import static io.jooby.internal.openapi.AsmUtils.*;
9+
10+
import io.jooby.Projection;
11+
import io.jooby.annotation.Project;
12+
import io.jooby.internal.openapi.OpenAPIExt;
13+
import io.jooby.internal.openapi.OperationExt;
14+
import io.jooby.internal.openapi.ParserContext;
15+
import io.jooby.value.ValueFactory;
16+
import io.swagger.v3.oas.models.media.Schema;
17+
18+
public class ProjectionParser {
19+
private OpenAPIExt openapi;
20+
private ParserContext ctx;
21+
22+
private ProjectionParser(ParserContext ctx, OpenAPIExt openapi) {
23+
this.ctx = ctx;
24+
this.openapi = openapi;
25+
}
26+
27+
public static void parse(ParserContext ctx, OpenAPIExt openapi) {
28+
var parser = new ProjectionParser(ctx, openapi);
29+
for (OperationExt operation : openapi.getOperations()) {
30+
parser.parseOperation(operation);
31+
}
32+
}
33+
34+
private void parseOperation(OperationExt operation) {
35+
var annotations = operation.getAllAnnotations();
36+
37+
var projection = operation.getProjection();
38+
if (projection != null) {
39+
projection(operation, projection);
40+
} else {
41+
findAnnotationByType(annotations, Project.class).stream()
42+
.map(it -> stringValue(toMap(it), "value"))
43+
.forEach(projectionView -> projection(operation, projectionView));
44+
}
45+
}
46+
47+
private void projection(OperationExt operation, String viewString) {
48+
var response = operation.getDefaultResponse();
49+
var javaType = ctx.javaType(response.getJavaType());
50+
if (javaType.isArrayType() || javaType.isCollectionLikeType()) {
51+
javaType = javaType.getContentType();
52+
}
53+
var valueFactory = new ValueFactory();
54+
var isSimple = valueFactory.get(javaType) != null;
55+
if (isSimple) {
56+
return;
57+
}
58+
var projection = Projection.of(javaType.getRawClass()).include(viewString);
59+
var content = response.getContent();
60+
for (var mediaTypes : content.entrySet()) {
61+
Schema<?> prune =
62+
SchemaPruner.prune(
63+
mediaTypes.getValue().getSchema(), projection, openapi.getComponents());
64+
mediaTypes.getValue().setSchema(prune);
65+
}
66+
}
67+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
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.projection;
7+
8+
import java.util.List;
9+
import java.util.Map;
10+
11+
import io.jooby.Projection;
12+
import io.swagger.v3.oas.models.Components;
13+
import io.swagger.v3.oas.models.media.ArraySchema;
14+
import io.swagger.v3.oas.models.media.ObjectSchema;
15+
import io.swagger.v3.oas.models.media.Schema;
16+
17+
/**
18+
* Utility to create a pruned OpenAPI Schema based on a Jooby Projection.
19+
*
20+
* @since 4.0.0
21+
*/
22+
public class SchemaPruner {
23+
24+
public static Schema<?> prune(
25+
Schema<?> original, Projection<?> projection, Components components) {
26+
// 1. Deep wildcard (e.g., address(*)). Return the original schema/ref untouched.
27+
if (projection.getChildren().isEmpty()) {
28+
return original;
29+
}
30+
31+
// 2. Handle Arrays: Recursively prune the items.
32+
if (original instanceof ArraySchema) {
33+
ArraySchema arraySchema = (ArraySchema) original;
34+
Schema<?> prunedItems = prune(arraySchema.getItems(), projection, components);
35+
36+
ArraySchema newArraySchema = new ArraySchema();
37+
copyMetadata(original, newArraySchema);
38+
newArraySchema.setItems(prunedItems);
39+
return newArraySchema;
40+
}
41+
42+
// --- THE CACHE CHECK (Early Exit) ---
43+
String baseName = getBaseName(original);
44+
String newComponentName = null;
45+
46+
if (baseName != null && components != null) {
47+
newComponentName = generateProjectedName(baseName, projection);
48+
49+
// If we already built this exact projection view, reuse it immediately!
50+
if (components.getSchemas() != null
51+
&& components.getSchemas().containsKey(newComponentName)) {
52+
return new Schema<>().$ref("#/components/schemas/" + newComponentName);
53+
}
54+
}
55+
// ------------------------------------
56+
57+
// 3. Resolve the actual properties from the original schema
58+
Schema<?> actualSchema = resolveSchema(original, components);
59+
if (actualSchema == null || actualSchema.getProperties() == null) {
60+
return original;
61+
}
62+
63+
// 4. Build the new pruned ObjectSchema
64+
Schema<?> prunedSchema = new ObjectSchema();
65+
copyMetadata(actualSchema, prunedSchema);
66+
Map<String, Schema> originalProps = actualSchema.getProperties();
67+
68+
for (Map.Entry<String, Projection<?>> entry : projection.getChildren().entrySet()) {
69+
String propName = entry.getKey();
70+
Projection<?> childNode = entry.getValue();
71+
Schema<?> originalPropSchema = originalProps.get(propName);
72+
73+
if (originalPropSchema != null) {
74+
// Recursion naturally handles leaf nodes vs. nested objects
75+
Schema<?> prunedProp = prune(originalPropSchema, childNode, components);
76+
prunedSchema.addProperty(propName, prunedProp);
77+
}
78+
}
79+
80+
// 5. Componentize! Register the new one and return a $ref.
81+
if (newComponentName != null && components != null) {
82+
// Add the fully built pruned schema to the global registry
83+
components.addSchemas(newComponentName, prunedSchema);
84+
85+
// Return a lightweight $ref pointer to the newly registered component
86+
return new Schema<>().$ref("#/components/schemas/" + newComponentName);
87+
}
88+
89+
// Fallback: If it was an anonymous inline object to begin with, just return the inline pruned
90+
// object.
91+
return prunedSchema;
92+
}
93+
94+
private static Schema<?> resolveSchema(Schema<?> schema, Components components) {
95+
if (schema.get$ref() != null && components != null && components.getSchemas() != null) {
96+
String refName = schema.get$ref().substring(schema.get$ref().lastIndexOf('/') + 1);
97+
return components.getSchemas().get(refName);
98+
}
99+
return schema;
100+
}
101+
102+
private static String getBaseName(Schema<?> schema) {
103+
if (schema.get$ref() != null) {
104+
return schema.get$ref().substring(schema.get$ref().lastIndexOf('/') + 1);
105+
}
106+
return schema.getName();
107+
}
108+
109+
private static String generateProjectedName(String baseName, Projection<?> projection) {
110+
String shortHash = Integer.toString(Math.abs(projection.toView().hashCode()), 36);
111+
return baseName + "_" + shortHash;
112+
}
113+
114+
private static void copyMetadata(Schema<?> source, Schema<?> target) {
115+
target.setName(source.getName());
116+
target.setTitle(source.getTitle());
117+
target.setDescription(source.getDescription());
118+
target.setFormat(source.getFormat());
119+
target.setDefault(source.getDefault());
120+
if (source.getExample() != null) {
121+
target.setExample(source.getExample());
122+
}
123+
target.setEnum((List) source.getEnum());
124+
target.setRequired(source.getRequired());
125+
126+
target.setMaximum(source.getMaximum());
127+
target.setMinimum(source.getMinimum());
128+
target.setMaxLength(source.getMaxLength());
129+
target.setMinLength(source.getMinLength());
130+
target.setPattern(source.getPattern());
131+
target.setMaxItems(source.getMaxItems());
132+
target.setMinItems(source.getMinItems());
133+
target.setUniqueItems(source.getUniqueItems());
134+
135+
if (source.getExtensions() != null) {
136+
source.getExtensions().forEach(target::addExtension);
137+
}
138+
}
139+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import io.jooby.internal.openapi.*;
2424
import io.jooby.internal.openapi.asciidoc.AsciiDocContext;
2525
import io.jooby.internal.openapi.javadoc.JavaDocParser;
26+
import io.jooby.internal.openapi.projection.ProjectionParser;
2627
import io.swagger.v3.core.util.*;
2728
import io.swagger.v3.oas.models.OpenAPI;
2829
import io.swagger.v3.oas.models.PathItem;
@@ -318,6 +319,8 @@ public OpenAPIGenerator() {}
318319
openapi.setJsonSchemaDialect(null);
319320
}
320321

322+
ProjectionParser.parse(ctx, openapi);
323+
321324
return openapi;
322325
}
323326

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
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 issues.i3853;
7+
8+
public class A3853 {
9+
private final String city;
10+
private final L3853 loc;
11+
12+
public A3853(String city, L3853 loc) {
13+
this.city = city;
14+
this.loc = loc;
15+
}
16+
17+
public String getCity() {
18+
return city;
19+
}
20+
21+
public L3853 getLoc() {
22+
return loc;
23+
}
24+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
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 issues.i3853;
7+
8+
import static io.jooby.openapi.MvcExtensionGenerator.toMvcExtension;
9+
10+
import io.jooby.Jooby;
11+
12+
public class App3853 extends Jooby {
13+
{
14+
mvc(toMvcExtension(C3853.class));
15+
}
16+
}

0 commit comments

Comments
 (0)