Skip to content

Commit f9da46f

Browse files
Copilotedburns
andcommitted
feat(java): add SchemaGenerator compile-time type-to-JSON-Schema utility (#1766)
* Initial plan * feat(java): add SchemaGenerator compile-time type-to-JSON-Schema utility Creates SchemaGenerator.java that maps javax.lang.model TypeMirror instances to JSON Schema source code literals (Map.of(...) expressions). Implements all 24 type mappings from the specification including: - Primitives and boxed types (int/Integer, long/Long, etc.) - String, UUID, OffsetDateTime - Collections (List<T>, Collection<T>, Set<T>) - Maps (Map<String, V> with typed values) - Arrays (String[]) - Enums (with constant enumeration) - Records and POJOs (with properties/required) - Optional<T>, OptionalInt, OptionalDouble - Sealed interfaces (oneOf) - JsonNode and Object (any) Also adds SchemaGeneratorTest using compilation-testing approach with javax.tools.JavaCompiler to exercise the generator at compile time. Closes #1759 * fix: address code review - remove unused param, handle all primitive types * fix(java): correct SimpleJavaFileObject override - getCharContent not getContent Co-authored-by: edburns <75821+edburns@users.noreply.github.com> * spotless * Remove .class files generated by test * spotless * fix: use Map.ofEntries for properties to avoid Map.of 10-entry limit Address review comment r3461777483: Map.of() only supports up to 10 key-value pairs. Switch properties maps in SchemaGenerator to use Map.ofEntries(Map.entry(...), ...) so records/POJOs/methods with >10 fields won't cause generated source compilation failures. Update SchemaGeneratorTest expectations to match the new format. * fix: add missing Byte/Short/Character boxed type mappings Address review comment r3461777428: Byte and Short now map to "integer", Character maps to "string", matching their primitive equivalents. Add tests for all three. * fix: add missing OptionalLong mapping in generateDeclaredTypeSchema Address review comment r3461777459: OptionalLong was handled in isOptionalType/unwrapOptional but missing from generateDeclaredTypeSchema, causing it to fall through to POJO introspection when used as a direct return type. Add the mapping and tests for OptionalInt, OptionalLong, and OptionalDouble. * fix: correct misleading @JsonSubTypes comment on sealed interface handling Address review comment r3461777579: the implementation uses getPermittedSubclasses() (Java sealed types), not Jackson annotations. * test: add sealed interface test for oneOf schema generation Address review comment r3461777685: the processor had special handling for TestSealed* types but no test exercised generateSealedSchema(). Add a test with a sealed interface (TestSealedShape) and two record permits (Circle, Rect) verifying the oneOf schema output. * test: add >10-field record test proving Map.ofEntries compiles Address review comment r3461777706: add a test with an 11-component record that verifies the generated Map.ofEntries(...) expression actually compiles, proving the Map.of 10-entry limit fix works end-to-end. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: edburns <75821+edburns@users.noreply.github.com> Co-authored-by: Ed Burns <edburns@microsoft.com>
1 parent 106fc3d commit f9da46f

3 files changed

Lines changed: 1084 additions & 0 deletions

File tree

‎java/mvnw‎

100644100755
File mode changed.
Lines changed: 384 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,384 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
package com.github.copilot.tool;
6+
7+
import java.util.ArrayList;
8+
import java.util.List;
9+
import java.util.stream.Collectors;
10+
11+
import javax.lang.model.element.Element;
12+
import javax.lang.model.element.ElementKind;
13+
import javax.lang.model.element.RecordComponentElement;
14+
import javax.lang.model.element.TypeElement;
15+
import javax.lang.model.element.VariableElement;
16+
import javax.lang.model.type.ArrayType;
17+
import javax.lang.model.type.DeclaredType;
18+
import javax.lang.model.type.TypeKind;
19+
import javax.lang.model.type.TypeMirror;
20+
import javax.lang.model.util.Elements;
21+
import javax.lang.model.util.Types;
22+
23+
/**
24+
* Compile-time utility that maps {@code javax.lang.model} types to JSON Schema
25+
* represented as Java source code literals ({@code Map.of(...)} expressions).
26+
*
27+
* <p>
28+
* This class is invoked by the annotation processor and operates exclusively
29+
* with the {@code javax.lang.model} API. It does NOT use
30+
* {@code java.lang.reflect}.
31+
*
32+
* @since 1.0.2
33+
*/
34+
public class SchemaGenerator {
35+
36+
/**
37+
* Given a {@link TypeMirror} from the annotation processing environment,
38+
* returns a {@code String} containing Java source code for a {@code Map}
39+
* literal representing the JSON Schema of that type.
40+
*
41+
* @param type
42+
* the type to generate schema for
43+
* @param typeUtils
44+
* the {@link Types} utility from the processing environment
45+
* @param elementUtils
46+
* the {@link Elements} utility from the processing environment
47+
* @return a Java source code string representing the JSON Schema
48+
*/
49+
public String generateSchemaSource(TypeMirror type, Types typeUtils, Elements elementUtils) {
50+
return generateSchema(type, typeUtils, elementUtils);
51+
}
52+
53+
/**
54+
* Generates the full "parameters" schema source for a method's parameters.
55+
* Produces a
56+
* {@code Map.of("type", "object", "properties", Map.of(...), "required", List.of(...))}.
57+
*
58+
* @param parameters
59+
* the method parameters to generate schema for
60+
* @param typeUtils
61+
* the {@link Types} utility from the processing environment
62+
* @param elementUtils
63+
* the {@link Elements} utility from the processing environment
64+
* @return a Java source code string representing the parameters JSON Schema
65+
*/
66+
public String generateParametersSchemaSource(List<? extends VariableElement> parameters, Types typeUtils,
67+
Elements elementUtils) {
68+
if (parameters.isEmpty()) {
69+
return "Map.of(\"type\", \"object\", \"properties\", Map.of(), \"required\", List.of())";
70+
}
71+
72+
List<String> propertyEntries = new ArrayList<>();
73+
List<String> requiredNames = new ArrayList<>();
74+
75+
for (VariableElement param : parameters) {
76+
String paramName = param.getSimpleName().toString();
77+
TypeMirror paramType = param.asType();
78+
79+
boolean isOptional = isOptionalType(paramType, typeUtils, elementUtils);
80+
String schema;
81+
if (isOptional) {
82+
schema = generateSchema(unwrapOptional(paramType, typeUtils, elementUtils), typeUtils, elementUtils);
83+
} else {
84+
schema = generateSchema(paramType, typeUtils, elementUtils);
85+
}
86+
87+
propertyEntries.add("Map.entry(\"" + paramName + "\", " + schema + ")");
88+
89+
if (!isOptional) {
90+
Param paramAnnotation = param.getAnnotation(Param.class);
91+
if (paramAnnotation == null || paramAnnotation.required()) {
92+
requiredNames.add("\"" + paramName + "\"");
93+
}
94+
}
95+
}
96+
97+
String properties = "Map.ofEntries(" + String.join(", ", propertyEntries) + ")";
98+
String required = "List.of(" + String.join(", ", requiredNames) + ")";
99+
100+
return "Map.of(\"type\", \"object\", \"properties\", " + properties + ", \"required\", " + required + ")";
101+
}
102+
103+
private String generateSchema(TypeMirror type, Types typeUtils, Elements elementUtils) {
104+
// Handle primitive types
105+
if (type.getKind().isPrimitive()) {
106+
return generatePrimitiveSchema(type.getKind());
107+
}
108+
109+
// Handle array types
110+
if (type.getKind() == TypeKind.ARRAY) {
111+
ArrayType arrayType = (ArrayType) type;
112+
TypeMirror componentType = arrayType.getComponentType();
113+
String itemsSchema = generateSchema(componentType, typeUtils, elementUtils);
114+
return "Map.of(\"type\", \"array\", \"items\", " + itemsSchema + ")";
115+
}
116+
117+
// Handle declared types (classes, interfaces, enums, records)
118+
if (type.getKind() == TypeKind.DECLARED) {
119+
return generateDeclaredTypeSchema((DeclaredType) type, typeUtils, elementUtils);
120+
}
121+
122+
// Fallback: any
123+
return "Map.of()";
124+
}
125+
126+
private String generatePrimitiveSchema(TypeKind kind) {
127+
switch (kind) {
128+
case INT :
129+
case LONG :
130+
case BYTE :
131+
case SHORT :
132+
return "Map.of(\"type\", \"integer\")";
133+
case DOUBLE :
134+
case FLOAT :
135+
return "Map.of(\"type\", \"number\")";
136+
case BOOLEAN :
137+
return "Map.of(\"type\", \"boolean\")";
138+
case CHAR :
139+
return "Map.of(\"type\", \"string\")";
140+
default :
141+
return "Map.of()";
142+
}
143+
}
144+
145+
private String generateDeclaredTypeSchema(DeclaredType type, Types typeUtils, Elements elementUtils) {
146+
TypeElement typeElement = (TypeElement) type.asElement();
147+
String qualifiedName = typeElement.getQualifiedName().toString();
148+
149+
// String
150+
if ("java.lang.String".equals(qualifiedName)) {
151+
return "Map.of(\"type\", \"string\")";
152+
}
153+
154+
// Boxed primitives
155+
if ("java.lang.Integer".equals(qualifiedName) || "java.lang.Long".equals(qualifiedName)
156+
|| "java.lang.Byte".equals(qualifiedName) || "java.lang.Short".equals(qualifiedName)) {
157+
return "Map.of(\"type\", \"integer\")";
158+
}
159+
if ("java.lang.Double".equals(qualifiedName) || "java.lang.Float".equals(qualifiedName)) {
160+
return "Map.of(\"type\", \"number\")";
161+
}
162+
if ("java.lang.Boolean".equals(qualifiedName)) {
163+
return "Map.of(\"type\", \"boolean\")";
164+
}
165+
if ("java.lang.Character".equals(qualifiedName)) {
166+
return "Map.of(\"type\", \"string\")";
167+
}
168+
169+
// UUID
170+
if ("java.util.UUID".equals(qualifiedName)) {
171+
return "Map.of(\"type\", \"string\", \"format\", \"uuid\")";
172+
}
173+
174+
// OffsetDateTime
175+
if ("java.time.OffsetDateTime".equals(qualifiedName)) {
176+
return "Map.of(\"type\", \"string\", \"format\", \"date-time\")";
177+
}
178+
179+
// JsonNode (any)
180+
if ("com.fasterxml.jackson.databind.JsonNode".equals(qualifiedName)) {
181+
return "Map.of()";
182+
}
183+
184+
// Object (any)
185+
if ("java.lang.Object".equals(qualifiedName)) {
186+
return "Map.of()";
187+
}
188+
189+
// Optional types
190+
if ("java.util.Optional".equals(qualifiedName)) {
191+
List<? extends TypeMirror> typeArgs = type.getTypeArguments();
192+
if (!typeArgs.isEmpty()) {
193+
return generateSchema(typeArgs.get(0), typeUtils, elementUtils);
194+
}
195+
return "Map.of()";
196+
}
197+
if ("java.util.OptionalInt".equals(qualifiedName)) {
198+
return "Map.of(\"type\", \"integer\")";
199+
}
200+
if ("java.util.OptionalDouble".equals(qualifiedName)) {
201+
return "Map.of(\"type\", \"number\")";
202+
}
203+
if ("java.util.OptionalLong".equals(qualifiedName)) {
204+
return "Map.of(\"type\", \"integer\")";
205+
}
206+
207+
// List / Collection
208+
if (isCollectionType(qualifiedName)) {
209+
List<? extends TypeMirror> typeArgs = type.getTypeArguments();
210+
if (!typeArgs.isEmpty()) {
211+
String itemsSchema = generateSchema(typeArgs.get(0), typeUtils, elementUtils);
212+
return "Map.of(\"type\", \"array\", \"items\", " + itemsSchema + ")";
213+
}
214+
return "Map.of(\"type\", \"array\")";
215+
}
216+
217+
// Map<String, V>
218+
if (isMapType(qualifiedName)) {
219+
List<? extends TypeMirror> typeArgs = type.getTypeArguments();
220+
if (typeArgs.size() == 2) {
221+
TypeMirror valueType = typeArgs.get(1);
222+
if (valueType.getKind() == TypeKind.DECLARED) {
223+
TypeElement valueElement = (TypeElement) ((DeclaredType) valueType).asElement();
224+
String valueQName = valueElement.getQualifiedName().toString();
225+
if ("java.lang.Object".equals(valueQName)) {
226+
return "Map.of(\"type\", \"object\")";
227+
}
228+
}
229+
String valueSchema = generateSchema(valueType, typeUtils, elementUtils);
230+
return "Map.of(\"type\", \"object\", \"additionalProperties\", " + valueSchema + ")";
231+
}
232+
return "Map.of(\"type\", \"object\")";
233+
}
234+
235+
// Enum types
236+
if (typeElement.getKind() == ElementKind.ENUM) {
237+
List<String> constants = typeElement.getEnclosedElements().stream()
238+
.filter(e -> e.getKind() == ElementKind.ENUM_CONSTANT)
239+
.map(e -> "\"" + e.getSimpleName().toString() + "\"").collect(Collectors.toList());
240+
return "Map.of(\"type\", \"string\", \"enum\", List.of(" + String.join(", ", constants) + "))";
241+
}
242+
243+
// Record types
244+
if (typeElement.getKind() == ElementKind.RECORD) {
245+
return generateRecordSchema(typeElement, typeUtils, elementUtils);
246+
}
247+
248+
// POJO / class types — treat as object with fields
249+
if (typeElement.getKind() == ElementKind.CLASS) {
250+
return generateClassSchema(typeElement, typeUtils, elementUtils);
251+
}
252+
253+
// Sealed interfaces — oneOf via permitted subclasses
254+
if (typeElement.getKind() == ElementKind.INTERFACE) {
255+
return generateSealedSchema(typeElement, typeUtils, elementUtils);
256+
}
257+
258+
return "Map.of()";
259+
}
260+
261+
private String generateRecordSchema(TypeElement typeElement, Types typeUtils, Elements elementUtils) {
262+
List<String> propertyEntries = new ArrayList<>();
263+
List<String> requiredNames = new ArrayList<>();
264+
265+
for (Element enclosed : typeElement.getEnclosedElements()) {
266+
if (enclosed.getKind() == ElementKind.RECORD_COMPONENT) {
267+
RecordComponentElement component = (RecordComponentElement) enclosed;
268+
String name = component.getSimpleName().toString();
269+
TypeMirror componentType = component.asType();
270+
271+
boolean isOptional = isOptionalType(componentType, typeUtils, elementUtils);
272+
String schema;
273+
if (isOptional) {
274+
schema = generateSchema(unwrapOptional(componentType, typeUtils, elementUtils), typeUtils,
275+
elementUtils);
276+
} else {
277+
schema = generateSchema(componentType, typeUtils, elementUtils);
278+
requiredNames.add("\"" + name + "\"");
279+
}
280+
281+
propertyEntries.add("Map.entry(\"" + name + "\", " + schema + ")");
282+
}
283+
}
284+
285+
String properties = "Map.ofEntries(" + String.join(", ", propertyEntries) + ")";
286+
String required = "List.of(" + String.join(", ", requiredNames) + ")";
287+
288+
return "Map.of(\"type\", \"object\", \"properties\", " + properties + ", \"required\", " + required + ")";
289+
}
290+
291+
private String generateClassSchema(TypeElement typeElement, Types typeUtils, Elements elementUtils) {
292+
List<String> propertyEntries = new ArrayList<>();
293+
List<String> requiredNames = new ArrayList<>();
294+
295+
for (Element enclosed : typeElement.getEnclosedElements()) {
296+
if (enclosed.getKind() == ElementKind.FIELD) {
297+
VariableElement field = (VariableElement) enclosed;
298+
// Skip static fields
299+
if (field.getModifiers().contains(javax.lang.model.element.Modifier.STATIC)) {
300+
continue;
301+
}
302+
String name = field.getSimpleName().toString();
303+
TypeMirror fieldType = field.asType();
304+
305+
boolean isOptional = isOptionalType(fieldType, typeUtils, elementUtils);
306+
String schema;
307+
if (isOptional) {
308+
schema = generateSchema(unwrapOptional(fieldType, typeUtils, elementUtils), typeUtils,
309+
elementUtils);
310+
} else {
311+
schema = generateSchema(fieldType, typeUtils, elementUtils);
312+
requiredNames.add("\"" + name + "\"");
313+
}
314+
315+
propertyEntries.add("Map.entry(\"" + name + "\", " + schema + ")");
316+
}
317+
}
318+
319+
if (propertyEntries.isEmpty()) {
320+
return "Map.of(\"type\", \"object\")";
321+
}
322+
323+
String properties = "Map.ofEntries(" + String.join(", ", propertyEntries) + ")";
324+
String required = "List.of(" + String.join(", ", requiredNames) + ")";
325+
326+
return "Map.of(\"type\", \"object\", \"properties\", " + properties + ", \"required\", " + required + ")";
327+
}
328+
329+
private String generateSealedSchema(TypeElement typeElement, Types typeUtils, Elements elementUtils) {
330+
List<? extends TypeMirror> permittedSubclasses = typeElement.getPermittedSubclasses();
331+
if (permittedSubclasses != null && !permittedSubclasses.isEmpty()) {
332+
List<String> schemas = permittedSubclasses.stream().map(sub -> generateSchema(sub, typeUtils, elementUtils))
333+
.collect(Collectors.toList());
334+
return "Map.of(\"oneOf\", List.of(" + String.join(", ", schemas) + "))";
335+
}
336+
return "Map.of(\"type\", \"object\")";
337+
}
338+
339+
private boolean isOptionalType(TypeMirror type, Types typeUtils, Elements elementUtils) {
340+
if (type.getKind() != TypeKind.DECLARED) {
341+
return false;
342+
}
343+
DeclaredType declaredType = (DeclaredType) type;
344+
TypeElement element = (TypeElement) declaredType.asElement();
345+
String name = element.getQualifiedName().toString();
346+
return "java.util.Optional".equals(name) || "java.util.OptionalInt".equals(name)
347+
|| "java.util.OptionalDouble".equals(name) || "java.util.OptionalLong".equals(name);
348+
}
349+
350+
private TypeMirror unwrapOptional(TypeMirror type, Types typeUtils, Elements elementUtils) {
351+
if (type.getKind() != TypeKind.DECLARED) {
352+
return type;
353+
}
354+
DeclaredType declaredType = (DeclaredType) type;
355+
TypeElement element = (TypeElement) declaredType.asElement();
356+
String name = element.getQualifiedName().toString();
357+
358+
if ("java.util.Optional".equals(name)) {
359+
List<? extends TypeMirror> typeArgs = declaredType.getTypeArguments();
360+
if (!typeArgs.isEmpty()) {
361+
return typeArgs.get(0);
362+
}
363+
}
364+
if ("java.util.OptionalInt".equals(name)) {
365+
return typeUtils.getPrimitiveType(TypeKind.INT);
366+
}
367+
if ("java.util.OptionalDouble".equals(name)) {
368+
return typeUtils.getPrimitiveType(TypeKind.DOUBLE);
369+
}
370+
if ("java.util.OptionalLong".equals(name)) {
371+
return typeUtils.getPrimitiveType(TypeKind.LONG);
372+
}
373+
return type;
374+
}
375+
376+
private boolean isCollectionType(String qualifiedName) {
377+
return "java.util.List".equals(qualifiedName) || "java.util.Collection".equals(qualifiedName)
378+
|| "java.util.Set".equals(qualifiedName);
379+
}
380+
381+
private boolean isMapType(String qualifiedName) {
382+
return "java.util.Map".equals(qualifiedName);
383+
}
384+
}

0 commit comments

Comments
 (0)