diff --git a/java/mvnw b/java/mvnw old mode 100644 new mode 100755 diff --git a/java/src/main/java/com/github/copilot/tool/SchemaGenerator.java b/java/src/main/java/com/github/copilot/tool/SchemaGenerator.java new file mode 100644 index 000000000..f2c92df85 --- /dev/null +++ b/java/src/main/java/com/github/copilot/tool/SchemaGenerator.java @@ -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). + * + *

+ * 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 parameters, Types typeUtils, + Elements elementUtils) { + if (parameters.isEmpty()) { + return "Map.of(\"type\", \"object\", \"properties\", Map.of(), \"required\", List.of())"; + } + + List propertyEntries = new ArrayList<>(); + List 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 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 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 + if (isMapType(qualifiedName)) { + List 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 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 propertyEntries = new ArrayList<>(); + List 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 propertyEntries = new ArrayList<>(); + List 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 permittedSubclasses = typeElement.getPermittedSubclasses(); + if (permittedSubclasses != null && !permittedSubclasses.isEmpty()) { + List 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 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); + } +} diff --git a/java/src/test/java/com/github/copilot/tool/SchemaGeneratorTest.java b/java/src/test/java/com/github/copilot/tool/SchemaGeneratorTest.java new file mode 100644 index 000000000..8e024ab9f --- /dev/null +++ b/java/src/test/java/com/github/copilot/tool/SchemaGeneratorTest.java @@ -0,0 +1,700 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.tool; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Set; + +import javax.annotation.processing.AbstractProcessor; +import javax.annotation.processing.ProcessingEnvironment; +import javax.annotation.processing.RoundEnvironment; +import javax.annotation.processing.SupportedAnnotationTypes; +import javax.annotation.processing.SupportedSourceVersion; +import javax.lang.model.SourceVersion; +import javax.lang.model.element.Element; +import javax.lang.model.element.ElementKind; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.TypeElement; +import javax.lang.model.element.VariableElement; +import javax.lang.model.type.TypeMirror; +import javax.lang.model.util.Elements; +import javax.lang.model.util.Types; +import javax.tools.DiagnosticCollector; +import javax.tools.JavaCompiler; +import javax.tools.JavaFileObject; +import javax.tools.SimpleJavaFileObject; +import javax.tools.StandardJavaFileManager; +import javax.tools.StandardLocation; +import javax.tools.ToolProvider; + +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link SchemaGenerator} using the compilation-testing approach. A + * test annotation processor exercises SchemaGenerator during compilation of + * small source snippets. + */ +public class SchemaGeneratorTest { + + /** + * In-memory Java source file for compilation testing. + */ + private static class InMemorySource extends SimpleJavaFileObject { + + private final String code; + + InMemorySource(String className, String code) { + super(URI.create("string:///" + className.replace('.', '/') + Kind.SOURCE.extension), Kind.SOURCE); + this.code = code; + } + + @Override + public CharSequence getCharContent(boolean ignoreEncodingErrors) throws IOException { + return code; + } + } + + /** + * Test processor that captures schema generation results. + */ + @SupportedAnnotationTypes("*") + @SupportedSourceVersion(SourceVersion.RELEASE_17) + public static class SchemaCapturingProcessor extends AbstractProcessor { + + static final List capturedSchemas = new ArrayList<>(); + static final List capturedParameterSchemas = new ArrayList<>(); + + private Types typeUtils; + private Elements elementUtils; + + @Override + public synchronized void init(ProcessingEnvironment processingEnv) { + super.init(processingEnv); + this.typeUtils = processingEnv.getTypeUtils(); + this.elementUtils = processingEnv.getElementUtils(); + } + + @Override + public boolean process(Set annotations, RoundEnvironment roundEnv) { + if (roundEnv.processingOver()) { + return false; + } + + SchemaGenerator generator = new SchemaGenerator(); + + for (Element rootElement : roundEnv.getRootElements()) { + if (rootElement.getKind() == ElementKind.CLASS || rootElement.getKind() == ElementKind.RECORD + || rootElement.getKind() == ElementKind.INTERFACE + || rootElement.getKind() == ElementKind.ENUM) { + // Find methods named "schemaTarget" to capture schemas for their return type + for (Element enclosed : rootElement.getEnclosedElements()) { + if (enclosed.getKind() == ElementKind.METHOD) { + ExecutableElement method = (ExecutableElement) enclosed; + String methodName = method.getSimpleName().toString(); + if (methodName.startsWith("schemaTarget")) { + TypeMirror returnType = method.getReturnType(); + String schema = generator.generateSchemaSource(returnType, typeUtils, elementUtils); + capturedSchemas.add(methodName + "=" + schema); + } + if ("parametersTarget".equals(methodName)) { + List params = method.getParameters(); + String schema = generator.generateParametersSchemaSource(params, typeUtils, + elementUtils); + capturedParameterSchemas.add(schema); + } + } + } + + // For record/enum types, generate schema for the type itself + TypeElement typeElement = (TypeElement) rootElement; + String typeName = typeElement.getSimpleName().toString(); + if (typeName.startsWith("TestRecord") || typeName.startsWith("TestEnum") + || typeName.startsWith("TestSealed")) { + String schema = generator.generateSchemaSource(typeElement.asType(), typeUtils, elementUtils); + capturedSchemas.add(typeName + "=" + schema); + } + } + } + + return false; + } + } + + private static final Path CLASS_OUTPUT_DIR = Path.of("target", "test-schema-classes"); + + /** + * Creates a StandardJavaFileManager that writes compiled .class files to + * target/test-schema-classes/ instead of the working directory. + */ + private StandardJavaFileManager createFileManager(JavaCompiler compiler, + DiagnosticCollector diagnostics) throws IOException { + Files.createDirectories(CLASS_OUTPUT_DIR); + StandardJavaFileManager fm = compiler.getStandardFileManager(diagnostics, null, null); + fm.setLocation(StandardLocation.CLASS_OUTPUT, List.of(CLASS_OUTPUT_DIR.toFile())); + return fm; + } + + private List compileAndCapture(String... sources) { + return compileAndCapture(Arrays.asList(sources)); + } + + private List compileAndCapture(List sourceTexts) { + SchemaCapturingProcessor.capturedSchemas.clear(); + SchemaCapturingProcessor.capturedParameterSchemas.clear(); + + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + assertNotNull(compiler, "System Java compiler not available"); + + DiagnosticCollector diagnostics = new DiagnosticCollector<>(); + + List compilationUnits = new ArrayList<>(); + for (String sourceText : sourceTexts) { + // Extract class name from source + String className = extractClassName(sourceText); + compilationUnits.add(new InMemorySource(className, sourceText)); + } + + try (StandardJavaFileManager fm = createFileManager(compiler, diagnostics)) { + // Compile with the processor on classpath + JavaCompiler.CompilationTask task = compiler.getTask(null, // writer + fm, // file manager + diagnostics, // diagnostics + List.of("--add-modules", "ALL-MODULE-PATH"), // options + null, // annotation classes + compilationUnits); + + task.setProcessors(List.of(new SchemaCapturingProcessor())); + boolean success = task.call(); + + if (!success) { + // Try without module options for simpler environments + diagnostics = new DiagnosticCollector<>(); + try (StandardJavaFileManager fm2 = createFileManager(compiler, diagnostics)) { + task = compiler.getTask(null, fm2, diagnostics, null, null, compilationUnits); + task.setProcessors(List.of(new SchemaCapturingProcessor())); + success = task.call(); + } + } + + assertTrue(success, "Compilation failed: " + diagnostics.getDiagnostics()); + } catch (IOException e) { + fail("Failed to create file manager: " + e.getMessage()); + } + return new ArrayList<>(SchemaCapturingProcessor.capturedSchemas); + } + + private List compileAndCaptureParams(String source) { + SchemaCapturingProcessor.capturedSchemas.clear(); + SchemaCapturingProcessor.capturedParameterSchemas.clear(); + + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + assertNotNull(compiler, "System Java compiler not available"); + + DiagnosticCollector diagnostics = new DiagnosticCollector<>(); + + String className = extractClassName(source); + List compilationUnits = List.of(new InMemorySource(className, source)); + + try (StandardJavaFileManager fm = createFileManager(compiler, diagnostics)) { + JavaCompiler.CompilationTask task = compiler.getTask(null, fm, diagnostics, null, null, compilationUnits); + task.setProcessors(List.of(new SchemaCapturingProcessor())); + boolean success = task.call(); + + assertTrue(success, "Compilation failed: " + diagnostics.getDiagnostics()); + } catch (IOException e) { + fail("Failed to create file manager: " + e.getMessage()); + } + return new ArrayList<>(SchemaCapturingProcessor.capturedParameterSchemas); + } + + private String extractClassName(String source) { + // Simple extraction: find "class X", "record X", "enum X", or "interface X" + for (String keyword : new String[]{"class ", "record ", "enum ", "interface "}) { + int idx = source.indexOf(keyword); + if (idx >= 0) { + int start = idx + keyword.length(); + int end = start; + while (end < source.length() && Character.isJavaIdentifierPart(source.charAt(end))) { + end++; + } + return source.substring(start, end); + } + } + return "Unknown"; + } + + // --- Type mapping tests --- + + @Test + void stringType() { + String source = """ + public class TestStringHolder { + public String schemaTargetString() { return null; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetString", "Map.of(\"type\", \"string\")"); + } + + @Test + void intPrimitiveType() { + String source = """ + public class TestIntHolder { + public int schemaTargetInt() { return 0; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetInt", "Map.of(\"type\", \"integer\")"); + } + + @Test + void integerBoxedType() { + String source = """ + public class TestIntegerHolder { + public Integer schemaTargetInteger() { return null; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetInteger", "Map.of(\"type\", \"integer\")"); + } + + @Test + void longType() { + String source = """ + public class TestLongHolder { + public long schemaTargetLong() { return 0L; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetLong", "Map.of(\"type\", \"integer\")"); + } + + @Test + void doubleType() { + String source = """ + public class TestDoubleHolder { + public double schemaTargetDouble() { return 0.0; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetDouble", "Map.of(\"type\", \"number\")"); + } + + @Test + void floatType() { + String source = """ + public class TestFloatHolder { + public float schemaTargetFloat() { return 0.0f; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetFloat", "Map.of(\"type\", \"number\")"); + } + + @Test + void booleanPrimitiveType() { + String source = """ + public class TestBooleanHolder { + public boolean schemaTargetBoolean() { return false; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetBoolean", "Map.of(\"type\", \"boolean\")"); + } + + @Test + void booleanBoxedType() { + String source = """ + public class TestBooleanBoxedHolder { + public Boolean schemaTargetBooleanBoxed() { return null; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetBooleanBoxed", "Map.of(\"type\", \"boolean\")"); + } + + @Test + void byteBoxedType() { + String source = """ + public class TestByteHolder { + public Byte schemaTargetByte() { return null; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetByte", "Map.of(\"type\", \"integer\")"); + } + + @Test + void shortBoxedType() { + String source = """ + public class TestShortHolder { + public Short schemaTargetShort() { return null; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetShort", "Map.of(\"type\", \"integer\")"); + } + + @Test + void characterBoxedType() { + String source = """ + public class TestCharHolder { + public Character schemaTargetChar() { return null; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetChar", "Map.of(\"type\", \"string\")"); + } + + @Test + void stringArrayType() { + String source = """ + public class TestArrayHolder { + public String[] schemaTargetArray() { return null; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetArray", + "Map.of(\"type\", \"array\", \"items\", Map.of(\"type\", \"string\"))"); + } + + @Test + void enumType() { + String source = """ + public enum TestEnumColor { RED, GREEN, BLUE } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "TestEnumColor", + "Map.of(\"type\", \"string\", \"enum\", List.of(\"RED\", \"GREEN\", \"BLUE\"))"); + } + + @Test + void listOfStringType() { + String source = """ + import java.util.List; + public class TestListHolder { + public List schemaTargetList() { return null; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetList", + "Map.of(\"type\", \"array\", \"items\", Map.of(\"type\", \"string\"))"); + } + + @Test + void mapStringStringType() { + String source = """ + import java.util.Map; + public class TestMapHolder { + public Map schemaTargetMap() { return null; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetMap", + "Map.of(\"type\", \"object\", \"additionalProperties\", Map.of(\"type\", \"string\"))"); + } + + @Test + void mapStringObjectType() { + String source = """ + import java.util.Map; + public class TestMapObjectHolder { + public Map schemaTargetMapObject() { return null; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetMapObject", "Map.of(\"type\", \"object\")"); + } + + @Test + void mapStringBooleanType() { + String source = """ + import java.util.Map; + public class TestMapBoolHolder { + public Map schemaTargetMapBool() { return null; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetMapBool", + "Map.of(\"type\", \"object\", \"additionalProperties\", Map.of(\"type\", \"boolean\"))"); + } + + @Test + void mapStringLongType() { + String source = """ + import java.util.Map; + public class TestMapLongHolder { + public Map schemaTargetMapLong() { return null; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetMapLong", + "Map.of(\"type\", \"object\", \"additionalProperties\", Map.of(\"type\", \"integer\"))"); + } + + @Test + void optionalStringType() { + String source = """ + import java.util.Optional; + public class TestOptionalHolder { + public Optional schemaTargetOptional() { return null; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetOptional", "Map.of(\"type\", \"string\")"); + } + + @Test + void optionalIntType() { + String source = """ + import java.util.OptionalInt; + public class TestOptionalIntHolder { + public OptionalInt schemaTargetOptionalInt() { return null; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetOptionalInt", "Map.of(\"type\", \"integer\")"); + } + + @Test + void optionalLongType() { + String source = """ + import java.util.OptionalLong; + public class TestOptionalLongHolder { + public OptionalLong schemaTargetOptionalLong() { return null; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetOptionalLong", "Map.of(\"type\", \"integer\")"); + } + + @Test + void optionalDoubleType() { + String source = """ + import java.util.OptionalDouble; + public class TestOptionalDoubleHolder { + public OptionalDouble schemaTargetOptionalDouble() { return null; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetOptionalDouble", "Map.of(\"type\", \"number\")"); + } + + @Test + void uuidType() { + String source = """ + import java.util.UUID; + public class TestUuidHolder { + public UUID schemaTargetUuid() { return null; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetUuid", "Map.of(\"type\", \"string\", \"format\", \"uuid\")"); + } + + @Test + void offsetDateTimeType() { + String source = """ + import java.time.OffsetDateTime; + public class TestDateTimeHolder { + public OffsetDateTime schemaTargetDateTime() { return null; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetDateTime", + "Map.of(\"type\", \"string\", \"format\", \"date-time\")"); + } + + @Test + void recordType() { + String source = """ + public record TestRecordPerson(String name, int age, boolean active) {} + """; + List schemas = compileAndCapture(source); + String expected = "Map.of(\"type\", \"object\", \"properties\", " + + "Map.ofEntries(Map.entry(\"name\", Map.of(\"type\", \"string\")), " + + "Map.entry(\"age\", Map.of(\"type\", \"integer\")), " + + "Map.entry(\"active\", Map.of(\"type\", \"boolean\"))), " + + "\"required\", List.of(\"name\", \"age\", \"active\"))"; + assertContainsSchema(schemas, "TestRecordPerson", expected); + } + + @Test + void recordWithOptionalField() { + String source = """ + import java.util.Optional; + public record TestRecordWithOptional(String name, Optional nickname) {} + """; + List schemas = compileAndCapture(source); + String expected = "Map.of(\"type\", \"object\", \"properties\", " + + "Map.ofEntries(Map.entry(\"name\", Map.of(\"type\", \"string\")), " + + "Map.entry(\"nickname\", Map.of(\"type\", \"string\"))), " + "\"required\", List.of(\"name\"))"; + assertContainsSchema(schemas, "TestRecordWithOptional", expected); + } + + @Test + void recordWithMoreThanTenFields() { + String source = """ + public record TestRecordLarge( + String f1, String f2, String f3, String f4, String f5, + String f6, String f7, String f8, String f9, String f10, + String f11) {} + """; + List schemas = compileAndCapture(source); + // Verify the schema contains all 11 fields and uses Map.ofEntries + String schema = schemas.stream().filter(s -> s.startsWith("TestRecordLarge=")).findFirst().orElse(""); + assertFalse(schema.isEmpty(), "Expected schema for TestRecordLarge"); + assertTrue(schema.contains("Map.ofEntries("), "Should use Map.ofEntries for >10 fields: " + schema); + assertTrue(schema.contains("Map.entry(\"f1\""), "Should have f1: " + schema); + assertTrue(schema.contains("Map.entry(\"f11\""), "Should have f11: " + schema); + // Verify the generated source expression is compilable by re-compiling it + String schemaExpr = schema.substring(schema.indexOf('=') + 1); + String validationSource = "import java.util.Map;\nimport java.util.List;\n" + + "public class LargeRecordValidation {\n" + " @SuppressWarnings(\"unchecked\")\n" + + " public Object schema() { return " + schemaExpr + "; }\n}\n"; + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + DiagnosticCollector diagnostics = new DiagnosticCollector<>(); + List units = List.of(new InMemorySource("LargeRecordValidation", validationSource)); + try (StandardJavaFileManager fm = createFileManager(compiler, diagnostics)) { + JavaCompiler.CompilationTask task = compiler.getTask(null, fm, diagnostics, null, null, units); + boolean success = task.call(); + assertTrue(success, "Generated schema for >10-field record does not compile: " + + diagnostics.getDiagnostics() + "\nSource:\n" + validationSource); + } catch (IOException e) { + fail("Failed to create file manager: " + e.getMessage()); + } + } + + @Test + void parametersSchema() { + String source = """ + public class TestParamsHolder { + public void parametersTarget(String query, int limit, boolean verbose) {} + } + """; + List paramSchemas = compileAndCaptureParams(source); + assertFalse(paramSchemas.isEmpty(), "Expected parameter schemas"); + String schema = paramSchemas.get(0); + assertTrue(schema.contains("\"type\", \"object\""), "Should be object type: " + schema); + assertTrue(schema.contains("Map.entry(\"query\", Map.of(\"type\", \"string\"))"), + "Should have query property: " + schema); + assertTrue(schema.contains("Map.entry(\"limit\", Map.of(\"type\", \"integer\"))"), + "Should have limit property: " + schema); + assertTrue(schema.contains("Map.entry(\"verbose\", Map.of(\"type\", \"boolean\"))"), + "Should have verbose property: " + schema); + assertTrue(schema.contains("\"required\", List.of("), "Should have required list: " + schema); + } + + @Test + void generatedSourceIsValidJava() { + // Verify that generated schema source code compiles when embedded in a method + // body + String source = """ + import java.util.List; + import java.util.Map; + import java.util.Optional; + public class TestValidJavaHolder { + public String schemaTargetStr() { return null; } + public List schemaTargetListStr() { return null; } + public Map schemaTargetMapStr() { return null; } + public Optional schemaTargetOpt() { return null; } + } + """; + List schemas = compileAndCapture(source); + assertFalse(schemas.isEmpty()); + + // Build a Java source that uses the generated schema expressions + StringBuilder validationSource = new StringBuilder(); + validationSource.append("import java.util.Map;\n"); + validationSource.append("import java.util.List;\n"); + validationSource.append("public class SchemaValidation {\n"); + validationSource.append(" @SuppressWarnings(\"unchecked\")\n"); + validationSource.append(" public void validate() {\n"); + for (int i = 0; i < schemas.size(); i++) { + String schema = schemas.get(i); + String schemaExpr = schema.substring(schema.indexOf('=') + 1); + validationSource.append(" Object s" + i + " = " + schemaExpr + ";\n"); + } + validationSource.append(" }\n"); + validationSource.append("}\n"); + + // Compile the validation source to verify syntactic validity + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + DiagnosticCollector diagnostics = new DiagnosticCollector<>(); + List compilationUnits = List + .of(new InMemorySource("SchemaValidation", validationSource.toString())); + + try (StandardJavaFileManager fm = createFileManager(compiler, diagnostics)) { + JavaCompiler.CompilationTask task = compiler.getTask(null, fm, diagnostics, null, null, compilationUnits); + boolean success = task.call(); + + assertTrue(success, "Generated schema source code is not valid Java: " + diagnostics.getDiagnostics() + + "\nSource:\n" + validationSource); + } catch (IOException e) { + fail("Failed to create file manager: " + e.getMessage()); + } + } + + @Test + void nestedMapListType() { + String source = """ + import java.util.List; + import java.util.Map; + public class TestNestedHolder { + public Map> schemaTargetNestedMap() { return null; } + } + """; + List schemas = compileAndCapture(source); + String expected = "Map.of(\"type\", \"object\", \"additionalProperties\", " + + "Map.of(\"type\", \"array\", \"items\", Map.of(\"type\", \"string\")))"; + assertContainsSchema(schemas, "schemaTargetNestedMap", expected); + } + + @Test + void objectType() { + String source = """ + public class TestObjectHolder { + public Object schemaTargetObject() { return null; } + } + """; + List schemas = compileAndCapture(source); + assertContainsSchema(schemas, "schemaTargetObject", "Map.of()"); + } + + @Test + void sealedInterfaceType() { + String sealedInterface = """ + public sealed interface TestSealedShape permits TestSealedCircle, TestSealedRect {} + """; + String circle = """ + public record TestSealedCircle(double radius) implements TestSealedShape {} + """; + String rect = """ + public record TestSealedRect(double width, double height) implements TestSealedShape {} + """; + List schemas = compileAndCapture(sealedInterface, circle, rect); + String expected = "Map.of(\"oneOf\", List.of(" + "Map.of(\"type\", \"object\", \"properties\", " + + "Map.ofEntries(Map.entry(\"radius\", Map.of(\"type\", \"number\"))), " + + "\"required\", List.of(\"radius\")), " + "Map.of(\"type\", \"object\", \"properties\", " + + "Map.ofEntries(Map.entry(\"width\", Map.of(\"type\", \"number\")), " + + "Map.entry(\"height\", Map.of(\"type\", \"number\"))), " + + "\"required\", List.of(\"width\", \"height\"))))"; + assertContainsSchema(schemas, "TestSealedShape", expected); + } + + private void assertContainsSchema(List schemas, String methodName, String expectedSchema) { + String expected = methodName + "=" + expectedSchema; + assertTrue(schemas.stream().anyMatch(s -> s.equals(expected)), + "Expected schema '" + expected + "' not found in: " + schemas); + } +}