-
Notifications
You must be signed in to change notification settings - Fork 1.3k
feat(java): add SchemaGenerator compile-time type-to-JSON-Schema utility #1766
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
edburns
merged 13 commits into
edburns/1682-java-tool-ergonomics
from
copilot/edburns1682-java-tool-ergonomics
Jun 23, 2026
+1,084
−0
Merged
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
e9fb3e6
Initial plan
Copilot 9523d1b
feat(java): add SchemaGenerator compile-time type-to-JSON-Schema utility
Copilot 5313a7a
fix: address code review - remove unused param, handle all primitive …
Copilot f5bde69
fix(java): correct SimpleJavaFileObject override - getCharContent not…
Copilot 8d265bf
spotless
edburns e608bd3
Remove .class files generated by test
edburns aa6cdcc
spotless
edburns 9f745e8
fix: use Map.ofEntries for properties to avoid Map.of 10-entry limit
edburns 52a5bbc
fix: add missing Byte/Short/Character boxed type mappings
edburns 79063d7
fix: add missing OptionalLong mapping in generateDeclaredTypeSchema
edburns 469b5f4
fix: correct misleading @JsonSubTypes comment on sealed interface han…
edburns aca4177
test: add sealed interface test for oneOf schema generation
edburns d5feff2
test: add >10-field record test proving Map.ofEntries compiles
edburns File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
Empty file.
384 changes: 384 additions & 0 deletions
384
java/src/main/java/com/github/copilot/tool/SchemaGenerator.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,384 @@ | ||
| /*--------------------------------------------------------------------------------------------- | ||
| * Copyright (c) Microsoft Corporation. All rights reserved. | ||
| *--------------------------------------------------------------------------------------------*/ | ||
|
|
||
| package com.github.copilot.tool; | ||
|
|
||
| import java.util.ArrayList; | ||
| import java.util.List; | ||
| import java.util.stream.Collectors; | ||
|
|
||
| import javax.lang.model.element.Element; | ||
| import javax.lang.model.element.ElementKind; | ||
| import javax.lang.model.element.RecordComponentElement; | ||
| import javax.lang.model.element.TypeElement; | ||
| import javax.lang.model.element.VariableElement; | ||
| import javax.lang.model.type.ArrayType; | ||
| import javax.lang.model.type.DeclaredType; | ||
| import javax.lang.model.type.TypeKind; | ||
| import javax.lang.model.type.TypeMirror; | ||
| import javax.lang.model.util.Elements; | ||
| import javax.lang.model.util.Types; | ||
|
|
||
| /** | ||
| * Compile-time utility that maps {@code javax.lang.model} types to JSON Schema | ||
| * represented as Java source code literals ({@code Map.of(...)} expressions). | ||
| * | ||
| * <p> | ||
| * This class is invoked by the annotation processor and operates exclusively | ||
| * with the {@code javax.lang.model} API. It does NOT use | ||
| * {@code java.lang.reflect}. | ||
| * | ||
| * @since 1.0.2 | ||
| */ | ||
| public class SchemaGenerator { | ||
|
|
||
| /** | ||
| * Given a {@link TypeMirror} from the annotation processing environment, | ||
| * returns a {@code String} containing Java source code for a {@code Map} | ||
| * literal representing the JSON Schema of that type. | ||
| * | ||
| * @param type | ||
| * the type to generate schema for | ||
| * @param typeUtils | ||
| * the {@link Types} utility from the processing environment | ||
| * @param elementUtils | ||
| * the {@link Elements} utility from the processing environment | ||
| * @return a Java source code string representing the JSON Schema | ||
| */ | ||
| public String generateSchemaSource(TypeMirror type, Types typeUtils, Elements elementUtils) { | ||
| return generateSchema(type, typeUtils, elementUtils); | ||
| } | ||
|
|
||
| /** | ||
| * Generates the full "parameters" schema source for a method's parameters. | ||
| * Produces a | ||
| * {@code Map.of("type", "object", "properties", Map.of(...), "required", List.of(...))}. | ||
| * | ||
| * @param parameters | ||
| * the method parameters to generate schema for | ||
| * @param typeUtils | ||
| * the {@link Types} utility from the processing environment | ||
| * @param elementUtils | ||
| * the {@link Elements} utility from the processing environment | ||
| * @return a Java source code string representing the parameters JSON Schema | ||
| */ | ||
| public String generateParametersSchemaSource(List<? extends VariableElement> parameters, Types typeUtils, | ||
| Elements elementUtils) { | ||
| if (parameters.isEmpty()) { | ||
| return "Map.of(\"type\", \"object\", \"properties\", Map.of(), \"required\", List.of())"; | ||
| } | ||
|
|
||
| List<String> propertyEntries = new ArrayList<>(); | ||
| List<String> requiredNames = new ArrayList<>(); | ||
|
|
||
| for (VariableElement param : parameters) { | ||
| String paramName = param.getSimpleName().toString(); | ||
| TypeMirror paramType = param.asType(); | ||
|
|
||
| boolean isOptional = isOptionalType(paramType, typeUtils, elementUtils); | ||
| String schema; | ||
| if (isOptional) { | ||
| schema = generateSchema(unwrapOptional(paramType, typeUtils, elementUtils), typeUtils, elementUtils); | ||
| } else { | ||
| schema = generateSchema(paramType, typeUtils, elementUtils); | ||
| } | ||
|
|
||
| propertyEntries.add("Map.entry(\"" + paramName + "\", " + schema + ")"); | ||
|
|
||
| if (!isOptional) { | ||
| Param paramAnnotation = param.getAnnotation(Param.class); | ||
| if (paramAnnotation == null || paramAnnotation.required()) { | ||
| requiredNames.add("\"" + paramName + "\""); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| String properties = "Map.ofEntries(" + String.join(", ", propertyEntries) + ")"; | ||
| String required = "List.of(" + String.join(", ", requiredNames) + ")"; | ||
|
|
||
| return "Map.of(\"type\", \"object\", \"properties\", " + properties + ", \"required\", " + required + ")"; | ||
| } | ||
|
|
||
| private String generateSchema(TypeMirror type, Types typeUtils, Elements elementUtils) { | ||
| // Handle primitive types | ||
| if (type.getKind().isPrimitive()) { | ||
| return generatePrimitiveSchema(type.getKind()); | ||
| } | ||
|
|
||
| // Handle array types | ||
| if (type.getKind() == TypeKind.ARRAY) { | ||
| ArrayType arrayType = (ArrayType) type; | ||
| TypeMirror componentType = arrayType.getComponentType(); | ||
| String itemsSchema = generateSchema(componentType, typeUtils, elementUtils); | ||
| return "Map.of(\"type\", \"array\", \"items\", " + itemsSchema + ")"; | ||
| } | ||
|
|
||
| // Handle declared types (classes, interfaces, enums, records) | ||
| if (type.getKind() == TypeKind.DECLARED) { | ||
| return generateDeclaredTypeSchema((DeclaredType) type, typeUtils, elementUtils); | ||
| } | ||
|
|
||
| // Fallback: any | ||
| return "Map.of()"; | ||
| } | ||
|
|
||
| private String generatePrimitiveSchema(TypeKind kind) { | ||
| switch (kind) { | ||
| case INT : | ||
| case LONG : | ||
| case BYTE : | ||
| case SHORT : | ||
| return "Map.of(\"type\", \"integer\")"; | ||
| case DOUBLE : | ||
| case FLOAT : | ||
| return "Map.of(\"type\", \"number\")"; | ||
| case BOOLEAN : | ||
| return "Map.of(\"type\", \"boolean\")"; | ||
| case CHAR : | ||
| return "Map.of(\"type\", \"string\")"; | ||
| default : | ||
| return "Map.of()"; | ||
| } | ||
| } | ||
|
|
||
| private String generateDeclaredTypeSchema(DeclaredType type, Types typeUtils, Elements elementUtils) { | ||
| TypeElement typeElement = (TypeElement) type.asElement(); | ||
| String qualifiedName = typeElement.getQualifiedName().toString(); | ||
|
|
||
| // String | ||
| if ("java.lang.String".equals(qualifiedName)) { | ||
| return "Map.of(\"type\", \"string\")"; | ||
| } | ||
|
|
||
| // Boxed primitives | ||
| if ("java.lang.Integer".equals(qualifiedName) || "java.lang.Long".equals(qualifiedName) | ||
| || "java.lang.Byte".equals(qualifiedName) || "java.lang.Short".equals(qualifiedName)) { | ||
| return "Map.of(\"type\", \"integer\")"; | ||
| } | ||
| if ("java.lang.Double".equals(qualifiedName) || "java.lang.Float".equals(qualifiedName)) { | ||
| return "Map.of(\"type\", \"number\")"; | ||
| } | ||
| if ("java.lang.Boolean".equals(qualifiedName)) { | ||
| return "Map.of(\"type\", \"boolean\")"; | ||
| } | ||
| if ("java.lang.Character".equals(qualifiedName)) { | ||
| return "Map.of(\"type\", \"string\")"; | ||
| } | ||
|
|
||
| // UUID | ||
| if ("java.util.UUID".equals(qualifiedName)) { | ||
| return "Map.of(\"type\", \"string\", \"format\", \"uuid\")"; | ||
| } | ||
|
|
||
| // OffsetDateTime | ||
| if ("java.time.OffsetDateTime".equals(qualifiedName)) { | ||
| return "Map.of(\"type\", \"string\", \"format\", \"date-time\")"; | ||
| } | ||
|
|
||
| // JsonNode (any) | ||
| if ("com.fasterxml.jackson.databind.JsonNode".equals(qualifiedName)) { | ||
| return "Map.of()"; | ||
| } | ||
|
|
||
| // Object (any) | ||
| if ("java.lang.Object".equals(qualifiedName)) { | ||
| return "Map.of()"; | ||
| } | ||
|
|
||
| // Optional types | ||
| if ("java.util.Optional".equals(qualifiedName)) { | ||
| List<? extends TypeMirror> typeArgs = type.getTypeArguments(); | ||
| if (!typeArgs.isEmpty()) { | ||
| return generateSchema(typeArgs.get(0), typeUtils, elementUtils); | ||
| } | ||
| return "Map.of()"; | ||
| } | ||
| if ("java.util.OptionalInt".equals(qualifiedName)) { | ||
| return "Map.of(\"type\", \"integer\")"; | ||
| } | ||
| if ("java.util.OptionalDouble".equals(qualifiedName)) { | ||
| return "Map.of(\"type\", \"number\")"; | ||
| } | ||
| if ("java.util.OptionalLong".equals(qualifiedName)) { | ||
| return "Map.of(\"type\", \"integer\")"; | ||
| } | ||
|
|
||
| // List / Collection | ||
| if (isCollectionType(qualifiedName)) { | ||
| List<? extends TypeMirror> typeArgs = type.getTypeArguments(); | ||
| if (!typeArgs.isEmpty()) { | ||
| String itemsSchema = generateSchema(typeArgs.get(0), typeUtils, elementUtils); | ||
| return "Map.of(\"type\", \"array\", \"items\", " + itemsSchema + ")"; | ||
| } | ||
| return "Map.of(\"type\", \"array\")"; | ||
| } | ||
|
|
||
| // Map<String, V> | ||
| if (isMapType(qualifiedName)) { | ||
| List<? extends TypeMirror> typeArgs = type.getTypeArguments(); | ||
| if (typeArgs.size() == 2) { | ||
| TypeMirror valueType = typeArgs.get(1); | ||
| if (valueType.getKind() == TypeKind.DECLARED) { | ||
| TypeElement valueElement = (TypeElement) ((DeclaredType) valueType).asElement(); | ||
| String valueQName = valueElement.getQualifiedName().toString(); | ||
| if ("java.lang.Object".equals(valueQName)) { | ||
| return "Map.of(\"type\", \"object\")"; | ||
| } | ||
| } | ||
| String valueSchema = generateSchema(valueType, typeUtils, elementUtils); | ||
| return "Map.of(\"type\", \"object\", \"additionalProperties\", " + valueSchema + ")"; | ||
| } | ||
| return "Map.of(\"type\", \"object\")"; | ||
| } | ||
|
|
||
| // Enum types | ||
| if (typeElement.getKind() == ElementKind.ENUM) { | ||
| List<String> constants = typeElement.getEnclosedElements().stream() | ||
| .filter(e -> e.getKind() == ElementKind.ENUM_CONSTANT) | ||
| .map(e -> "\"" + e.getSimpleName().toString() + "\"").collect(Collectors.toList()); | ||
| return "Map.of(\"type\", \"string\", \"enum\", List.of(" + String.join(", ", constants) + "))"; | ||
| } | ||
|
|
||
| // Record types | ||
| if (typeElement.getKind() == ElementKind.RECORD) { | ||
| return generateRecordSchema(typeElement, typeUtils, elementUtils); | ||
| } | ||
|
|
||
| // POJO / class types — treat as object with fields | ||
| if (typeElement.getKind() == ElementKind.CLASS) { | ||
| return generateClassSchema(typeElement, typeUtils, elementUtils); | ||
| } | ||
|
|
||
| // Sealed interfaces — oneOf via permitted subclasses | ||
| if (typeElement.getKind() == ElementKind.INTERFACE) { | ||
| return generateSealedSchema(typeElement, typeUtils, elementUtils); | ||
| } | ||
|
|
||
| return "Map.of()"; | ||
| } | ||
|
|
||
| private String generateRecordSchema(TypeElement typeElement, Types typeUtils, Elements elementUtils) { | ||
| List<String> propertyEntries = new ArrayList<>(); | ||
| List<String> requiredNames = new ArrayList<>(); | ||
|
|
||
| for (Element enclosed : typeElement.getEnclosedElements()) { | ||
| if (enclosed.getKind() == ElementKind.RECORD_COMPONENT) { | ||
| RecordComponentElement component = (RecordComponentElement) enclosed; | ||
| String name = component.getSimpleName().toString(); | ||
| TypeMirror componentType = component.asType(); | ||
|
|
||
| boolean isOptional = isOptionalType(componentType, typeUtils, elementUtils); | ||
| String schema; | ||
| if (isOptional) { | ||
| schema = generateSchema(unwrapOptional(componentType, typeUtils, elementUtils), typeUtils, | ||
| elementUtils); | ||
| } else { | ||
| schema = generateSchema(componentType, typeUtils, elementUtils); | ||
| requiredNames.add("\"" + name + "\""); | ||
| } | ||
|
|
||
| propertyEntries.add("Map.entry(\"" + name + "\", " + schema + ")"); | ||
| } | ||
| } | ||
|
|
||
| String properties = "Map.ofEntries(" + String.join(", ", propertyEntries) + ")"; | ||
| String required = "List.of(" + String.join(", ", requiredNames) + ")"; | ||
|
|
||
| return "Map.of(\"type\", \"object\", \"properties\", " + properties + ", \"required\", " + required + ")"; | ||
| } | ||
|
|
||
| private String generateClassSchema(TypeElement typeElement, Types typeUtils, Elements elementUtils) { | ||
| List<String> propertyEntries = new ArrayList<>(); | ||
| List<String> requiredNames = new ArrayList<>(); | ||
|
|
||
| for (Element enclosed : typeElement.getEnclosedElements()) { | ||
| if (enclosed.getKind() == ElementKind.FIELD) { | ||
| VariableElement field = (VariableElement) enclosed; | ||
| // Skip static fields | ||
| if (field.getModifiers().contains(javax.lang.model.element.Modifier.STATIC)) { | ||
| continue; | ||
| } | ||
| String name = field.getSimpleName().toString(); | ||
| TypeMirror fieldType = field.asType(); | ||
|
|
||
| boolean isOptional = isOptionalType(fieldType, typeUtils, elementUtils); | ||
| String schema; | ||
| if (isOptional) { | ||
| schema = generateSchema(unwrapOptional(fieldType, typeUtils, elementUtils), typeUtils, | ||
| elementUtils); | ||
| } else { | ||
| schema = generateSchema(fieldType, typeUtils, elementUtils); | ||
| requiredNames.add("\"" + name + "\""); | ||
| } | ||
|
|
||
| propertyEntries.add("Map.entry(\"" + name + "\", " + schema + ")"); | ||
| } | ||
| } | ||
|
|
||
| if (propertyEntries.isEmpty()) { | ||
| return "Map.of(\"type\", \"object\")"; | ||
| } | ||
|
|
||
| String properties = "Map.ofEntries(" + String.join(", ", propertyEntries) + ")"; | ||
| String required = "List.of(" + String.join(", ", requiredNames) + ")"; | ||
|
|
||
| return "Map.of(\"type\", \"object\", \"properties\", " + properties + ", \"required\", " + required + ")"; | ||
| } | ||
|
|
||
| private String generateSealedSchema(TypeElement typeElement, Types typeUtils, Elements elementUtils) { | ||
| List<? extends TypeMirror> permittedSubclasses = typeElement.getPermittedSubclasses(); | ||
| if (permittedSubclasses != null && !permittedSubclasses.isEmpty()) { | ||
| List<String> schemas = permittedSubclasses.stream().map(sub -> generateSchema(sub, typeUtils, elementUtils)) | ||
| .collect(Collectors.toList()); | ||
| return "Map.of(\"oneOf\", List.of(" + String.join(", ", schemas) + "))"; | ||
| } | ||
| return "Map.of(\"type\", \"object\")"; | ||
| } | ||
|
|
||
| private boolean isOptionalType(TypeMirror type, Types typeUtils, Elements elementUtils) { | ||
| if (type.getKind() != TypeKind.DECLARED) { | ||
| return false; | ||
| } | ||
| DeclaredType declaredType = (DeclaredType) type; | ||
| TypeElement element = (TypeElement) declaredType.asElement(); | ||
| String name = element.getQualifiedName().toString(); | ||
| return "java.util.Optional".equals(name) || "java.util.OptionalInt".equals(name) | ||
| || "java.util.OptionalDouble".equals(name) || "java.util.OptionalLong".equals(name); | ||
| } | ||
|
|
||
| private TypeMirror unwrapOptional(TypeMirror type, Types typeUtils, Elements elementUtils) { | ||
| if (type.getKind() != TypeKind.DECLARED) { | ||
| return type; | ||
| } | ||
| DeclaredType declaredType = (DeclaredType) type; | ||
| TypeElement element = (TypeElement) declaredType.asElement(); | ||
| String name = element.getQualifiedName().toString(); | ||
|
|
||
| if ("java.util.Optional".equals(name)) { | ||
| List<? extends TypeMirror> typeArgs = declaredType.getTypeArguments(); | ||
| if (!typeArgs.isEmpty()) { | ||
| return typeArgs.get(0); | ||
| } | ||
| } | ||
| if ("java.util.OptionalInt".equals(name)) { | ||
| return typeUtils.getPrimitiveType(TypeKind.INT); | ||
| } | ||
| if ("java.util.OptionalDouble".equals(name)) { | ||
| return typeUtils.getPrimitiveType(TypeKind.DOUBLE); | ||
| } | ||
| if ("java.util.OptionalLong".equals(name)) { | ||
| return typeUtils.getPrimitiveType(TypeKind.LONG); | ||
| } | ||
| return type; | ||
| } | ||
|
|
||
| private boolean isCollectionType(String qualifiedName) { | ||
| return "java.util.List".equals(qualifiedName) || "java.util.Collection".equals(qualifiedName) | ||
| || "java.util.Set".equals(qualifiedName); | ||
| } | ||
|
|
||
| private boolean isMapType(String qualifiedName) { | ||
| return "java.util.Map".equals(qualifiedName); | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.