diff --git a/graphql-apt/src/main/java/feign/graphql/apt/GraphqlSchemaProcessor.java b/graphql-apt/src/main/java/feign/graphql/apt/GraphqlSchemaProcessor.java index 1ae168b8b3..00ade4099c 100644 --- a/graphql-apt/src/main/java/feign/graphql/apt/GraphqlSchemaProcessor.java +++ b/graphql-apt/src/main/java/feign/graphql/apt/GraphqlSchemaProcessor.java @@ -17,18 +17,16 @@ import com.google.auto.service.AutoService; import com.squareup.javapoet.TypeName; +import feign.graphql.GraphqlField; import feign.graphql.GraphqlQuery; import feign.graphql.GraphqlSchema; import feign.graphql.Scalar; +import feign.graphql.Toggle; import graphql.language.Document; import graphql.language.Field; -import graphql.language.FieldDefinition; -import graphql.language.ListType; -import graphql.language.NonNullType; import graphql.language.ObjectTypeDefinition; import graphql.language.OperationDefinition; import graphql.language.SelectionSet; -import graphql.language.Type; import graphql.language.VariableDefinition; import graphql.parser.Parser; import graphql.schema.GraphQLSchema; @@ -39,6 +37,8 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.TreeSet; +import java.util.function.Supplier; import javax.annotation.processing.AbstractProcessor; import javax.annotation.processing.Filer; import javax.annotation.processing.Messager; @@ -48,10 +48,13 @@ 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.ExecutableElement; import javax.lang.model.element.PackageElement; import javax.lang.model.element.TypeElement; import javax.lang.model.type.DeclaredType; +import javax.lang.model.type.MirroredTypeException; +import javax.lang.model.type.MirroredTypesException; import javax.lang.model.type.TypeKind; import javax.lang.model.type.TypeMirror; import javax.tools.Diagnostic; @@ -117,6 +120,9 @@ private void processInterface(TypeElement typeElement) { var validator = new QueryValidator(messager); var generator = new TypeGenerator(filer, messager, registry, typeMapper, targetPackage); + var classFieldAnnotations = extractFieldAnnotations(typeElement); + var classConfig = resolveClassConfig(schemaAnnotation, classFieldAnnotations); + for (var enclosed : typeElement.getEnclosedElements()) { if (!(enclosed instanceof ExecutableElement method)) { continue; @@ -126,6 +132,9 @@ private void processInterface(TypeElement typeElement) { continue; } + var methodConfig = resolveMethodConfig(method, queryAnnotation, classConfig); + generator.setAnnotationConfig(methodConfig); + processMethod( method, queryAnnotation, @@ -242,9 +251,9 @@ private void processMethod( if (rootType != null) { var rootField = findRootField(operation.getSelectionSet()); if (rootField != null && rootField.getSelectionSet() != null) { - var rootFieldDef = findFieldDefinition(rootType, rootField.getName()); + var rootFieldDef = GraphqlTypeMapper.findFieldDefinition(rootType, rootField.getName()); if (rootFieldDef != null) { - var fieldTypeName = unwrapTypeName(rootFieldDef.getType()); + var fieldTypeName = GraphqlTypeMapper.unwrapTypeName(rootFieldDef.getType()); var fieldObjectType = registry.getType(fieldTypeName, ObjectTypeDefinition.class).orElse(null); if (fieldObjectType != null) { @@ -297,57 +306,32 @@ private Field findRootField(SelectionSet selectionSet) { return null; } - private FieldDefinition findFieldDefinition(ObjectTypeDefinition typeDef, String fieldName) { - for (var fd : typeDef.getFieldDefinitions()) { - if (fd.getName().equals(fieldName)) { - return fd; - } - } - return null; - } - private ObjectTypeDefinition getRootType( OperationDefinition operation, TypeDefinitionRegistry registry) { - var rootTypeName = + var operationName = switch (operation.getOperation()) { - case MUTATION -> - registry - .schemaDefinition() - .flatMap( - sd -> - sd.getOperationTypeDefinitions().stream() - .filter(otd -> otd.getName().equals("mutation")) - .findFirst()) - .map(otd -> otd.getTypeName().getName()) - .orElse("Mutation"); - case SUBSCRIPTION -> - registry - .schemaDefinition() - .flatMap( - sd -> - sd.getOperationTypeDefinitions().stream() - .filter(otd -> otd.getName().equals("subscription")) - .findFirst()) - .map(otd -> otd.getTypeName().getName()) - .orElse("Subscription"); - default -> - registry - .schemaDefinition() - .flatMap( - sd -> - sd.getOperationTypeDefinitions().stream() - .filter(otd -> otd.getName().equals("query")) - .findFirst()) - .map(otd -> otd.getTypeName().getName()) - .orElse("Query"); + case MUTATION -> "mutation"; + case SUBSCRIPTION -> "subscription"; + default -> "query"; }; + var fallback = Character.toUpperCase(operationName.charAt(0)) + operationName.substring(1); + var rootTypeName = + registry + .schemaDefinition() + .flatMap( + sd -> + sd.getOperationTypeDefinitions().stream() + .filter(otd -> otd.getName().equals(operationName)) + .findFirst()) + .map(otd -> otd.getTypeName().getName()) + .orElse(fallback); return registry.getType(rootTypeName, ObjectTypeDefinition.class).orElse(null); } private String findGraphqlInputType( String javaParamTypeName, List variableDefs) { for (var varDef : variableDefs) { - var graphqlTypeName = unwrapTypeName(varDef.getType()); + var graphqlTypeName = GraphqlTypeMapper.unwrapTypeName(varDef.getType()); if (graphqlTypeName.equals(javaParamTypeName)) { return graphqlTypeName; } @@ -355,19 +339,6 @@ private String findGraphqlInputType( return javaParamTypeName; } - private String unwrapTypeName(Type type) { - if (type instanceof NonNullType nullType) { - return unwrapTypeName(nullType.getType()); - } - if (type instanceof ListType listType) { - return unwrapTypeName(listType.getType()); - } - if (type instanceof graphql.language.TypeName name) { - return name.getName(); - } - return "String"; - } - private static final Set JAVA_BUILT_INS = Set.of( "String", @@ -434,6 +405,129 @@ private TypeMirror unwrapListTypeMirror(TypeMirror typeMirror) { return typeMirror; } + private TypeAnnotationConfig resolveClassConfig( + GraphqlSchema annotation, + Map classFieldAnnotations) { + var fqns = extractClassFqns(annotation::typeAnnotations); + var rawAnnotations = annotation.rawTypeAnnotations(); + var usesFqns = extractClassFqns(annotation::uses); + var nonNullFqns = extractClassFqns(annotation::nonNullTypeAnnotations); + var nonNullRaw = annotation.nonNullRawTypeAnnotations(); + var config = + TypeAnnotationConfig.resolve( + fqns, + rawAnnotations, + annotation.useOptional(), + classFieldAnnotations, + nonNullFqns, + nonNullRaw); + if (usesFqns.isEmpty()) { + return config; + } + var mergedImports = new TreeSet<>(config.imports()); + for (var fqn : usesFqns) { + if (!fqn.startsWith("java.lang.")) { + mergedImports.add(fqn); + } + } + return new TypeAnnotationConfig( + mergedImports, + config.annotations(), + config.useOptional(), + config.fieldAnnotations(), + config.nonNullAnnotations()); + } + + private static List extractClassFqns(Supplier[]> accessor) { + try { + var classes = accessor.get(); + return java.util.Arrays.stream(classes).map(Class::getCanonicalName).toList(); + } catch (MirroredTypesException e) { + return e.getTypeMirrors().stream().map(TypeMirror::toString).toList(); + } + } + + private TypeAnnotationConfig resolveMethodConfig( + ExecutableElement method, GraphqlQuery annotation, TypeAnnotationConfig classConfig) { + var methodFqns = extractClassFqns(annotation::typeAnnotations); + var methodRaw = annotation.rawTypeAnnotations(); + var methodToggle = annotation.useOptional(); + + var useOptional = + methodToggle == Toggle.INHERIT ? classConfig.useOptional() : methodToggle == Toggle.TRUE; + + var methodFieldAnnotations = extractFieldAnnotations(method); + var fieldAnnotations = + TypeAnnotationConfig.FieldAnnotations.merge( + classConfig.fieldAnnotations(), methodFieldAnnotations); + + var methodNonNullFqns = extractClassFqns(annotation::nonNullTypeAnnotations); + var methodNonNullRaw = annotation.nonNullRawTypeAnnotations(); + boolean hasMethodNonNull = !methodNonNullFqns.isEmpty() || methodNonNullRaw.length > 0; + var resolvedNonNull = hasMethodNonNull ? null : classConfig.nonNullAnnotations(); + + boolean hasMethodAnnotations = !methodFqns.isEmpty() || methodRaw.length > 0; + if (!hasMethodAnnotations && !hasMethodNonNull) { + if (useOptional == classConfig.useOptional() + && fieldAnnotations.equals(classConfig.fieldAnnotations())) { + return classConfig; + } + var mergedImports = new TreeSet<>(classConfig.imports()); + for (var fa : fieldAnnotations.values()) { + mergedImports.addAll(fa.imports()); + } + return new TypeAnnotationConfig( + mergedImports, + classConfig.annotations(), + useOptional, + fieldAnnotations, + classConfig.nonNullAnnotations()); + } + + var nonNullFqns = hasMethodNonNull ? methodNonNullFqns : List.of(); + var nonNullRaw = hasMethodNonNull ? methodNonNullRaw : new String[0]; + var config = + TypeAnnotationConfig.resolve( + methodFqns, methodRaw, useOptional, fieldAnnotations, nonNullFqns, nonNullRaw); + + if (resolvedNonNull != null && !resolvedNonNull.isEmpty()) { + var mergedImports = new TreeSet<>(config.imports()); + mergedImports.addAll(classConfig.imports()); + return new TypeAnnotationConfig( + mergedImports, config.annotations(), useOptional, fieldAnnotations, resolvedNonNull); + } + + return config; + } + + private Map extractFieldAnnotations( + Element method) { + var fieldAnnotations = new HashMap(); + var graphqlFields = method.getAnnotationsByType(GraphqlField.class); + for (var gf : graphqlFields) { + var fqns = extractClassFqns(gf::typeAnnotations); + var typeOverride = extractFieldTypeOverride(gf); + var resolved = + TypeAnnotationConfig.FieldAnnotations.resolve( + fqns, gf.rawTypeAnnotations(), typeOverride); + fieldAnnotations.put(gf.name(), resolved); + } + return fieldAnnotations; + } + + private String extractFieldTypeOverride(GraphqlField annotation) { + try { + var cls = annotation.type(); + if (cls == Void.class) { + return null; + } + return cls.getCanonicalName(); + } catch (MirroredTypeException e) { + var fqn = e.getTypeMirror().toString(); + return "java.lang.Void".equals(fqn) ? null : fqn; + } + } + private String getPackageName(TypeElement typeElement) { var enclosing = typeElement.getEnclosingElement(); while (enclosing != null && !(enclosing instanceof PackageElement)) { diff --git a/graphql-apt/src/main/java/feign/graphql/apt/GraphqlTypeMapper.java b/graphql-apt/src/main/java/feign/graphql/apt/GraphqlTypeMapper.java index d9be1fa18f..240b17983b 100644 --- a/graphql-apt/src/main/java/feign/graphql/apt/GraphqlTypeMapper.java +++ b/graphql-apt/src/main/java/feign/graphql/apt/GraphqlTypeMapper.java @@ -18,12 +18,15 @@ import com.squareup.javapoet.ClassName; import com.squareup.javapoet.ParameterizedTypeName; import com.squareup.javapoet.TypeName; +import graphql.language.FieldDefinition; import graphql.language.ListType; import graphql.language.NonNullType; +import graphql.language.ObjectTypeDefinition; import graphql.language.Type; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; public class GraphqlTypeMapper { @@ -44,11 +47,24 @@ public GraphqlTypeMapper(String targetPackage, Map customScala } public TypeName map(Type type) { + return map(type, false); + } + + public TypeName map(Type type, boolean useOptional) { + boolean nullable = !(type instanceof NonNullType); + var mapped = mapInner(type); + if (useOptional && nullable) { + return ParameterizedTypeName.get(ClassName.get(Optional.class), mapped); + } + return mapped; + } + + private TypeName mapInner(Type type) { if (type instanceof NonNullType nullType) { - return map(nullType.getType()); + return mapInner(nullType.getType()); } if (type instanceof ListType listType) { - var elementType = map(listType.getType()); + var elementType = mapInner(listType.getType()); return ParameterizedTypeName.get(ClassName.get(List.class), elementType); } if (type instanceof graphql.language.TypeName name) { @@ -72,4 +88,26 @@ private TypeName mapScalarOrNamed(String name) { public boolean isScalar(String name) { return BUILT_IN_SCALARS.containsKey(name) || customScalars.containsKey(name); } + + static String unwrapTypeName(Type type) { + if (type instanceof NonNullType nullType) { + return unwrapTypeName(nullType.getType()); + } + if (type instanceof ListType listType) { + return unwrapTypeName(listType.getType()); + } + if (type instanceof graphql.language.TypeName name) { + return name.getName(); + } + return "String"; + } + + static FieldDefinition findFieldDefinition(ObjectTypeDefinition typeDef, String fieldName) { + for (var fd : typeDef.getFieldDefinitions()) { + if (fd.getName().equals(fieldName)) { + return fd; + } + } + return null; + } } diff --git a/graphql-apt/src/main/java/feign/graphql/apt/TypeAnnotationConfig.java b/graphql-apt/src/main/java/feign/graphql/apt/TypeAnnotationConfig.java new file mode 100644 index 0000000000..a1d17e0f1b --- /dev/null +++ b/graphql-apt/src/main/java/feign/graphql/apt/TypeAnnotationConfig.java @@ -0,0 +1,136 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.graphql.apt; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +record TypeAnnotationConfig( + Set imports, + List annotations, + boolean useOptional, + Map fieldAnnotations, + List nonNullAnnotations) { + + static final TypeAnnotationConfig EMPTY = + new TypeAnnotationConfig(Set.of(), List.of(), false, Map.of(), List.of()); + + static TypeAnnotationConfig resolve( + List typeAnnotationFqns, String[] rawTypeAnnotations, boolean useOptional) { + return resolve( + typeAnnotationFqns, rawTypeAnnotations, useOptional, Map.of(), List.of(), new String[0]); + } + + static TypeAnnotationConfig resolve( + List typeAnnotationFqns, + String[] rawTypeAnnotations, + boolean useOptional, + Map fieldAnnotations) { + return resolve( + typeAnnotationFqns, + rawTypeAnnotations, + useOptional, + fieldAnnotations, + List.of(), + new String[0]); + } + + static TypeAnnotationConfig resolve( + List typeAnnotationFqns, + String[] rawTypeAnnotations, + boolean useOptional, + Map fieldAnnotations, + List nonNullFqns, + String[] nonNullRawAnnotations) { + + var imports = new TreeSet(); + var annotations = resolveAnnotationList(typeAnnotationFqns, rawTypeAnnotations, imports); + + for (var fa : fieldAnnotations.values()) { + imports.addAll(fa.imports()); + } + + var nonNullResolved = resolveAnnotationList(nonNullFqns, nonNullRawAnnotations, imports); + + return new TypeAnnotationConfig( + imports, annotations, useOptional, fieldAnnotations, nonNullResolved); + } + + static List resolveAnnotationList( + List fqns, String[] rawAnnotations, Set imports) { + var rawSimpleNames = new HashSet(); + for (var raw : rawAnnotations) { + var stripped = raw.startsWith("@") ? raw.substring(1) : raw; + var parenIdx = stripped.indexOf('('); + rawSimpleNames.add(parenIdx > 0 ? stripped.substring(0, parenIdx).trim() : stripped.trim()); + } + + var annotations = new ArrayList(); + + for (var fqn : fqns) { + var simpleName = fqn.substring(fqn.lastIndexOf('.') + 1); + if (!fqn.startsWith("java.lang.")) { + imports.add(fqn); + } + if (!rawSimpleNames.contains(simpleName)) { + annotations.add("@" + simpleName); + } + } + + for (var raw : rawAnnotations) { + annotations.add(raw.startsWith("@") ? raw : "@" + raw); + } + + return annotations; + } + + record FieldAnnotations(Set imports, List annotations, String typeOverride) { + + static FieldAnnotations resolve( + List fqns, String[] rawAnnotations, String typeOverride) { + var imports = new TreeSet(); + var annotations = resolveAnnotationList(fqns, rawAnnotations, imports); + + if (typeOverride != null) { + var simpleName = typeOverride.substring(typeOverride.lastIndexOf('.') + 1); + if (!typeOverride.startsWith("java.lang.")) { + imports.add(typeOverride); + } + return new FieldAnnotations(imports, annotations, simpleName); + } + + return new FieldAnnotations(imports, annotations, null); + } + + static Map merge( + Map classLevel, Map methodLevel) { + if (methodLevel.isEmpty()) { + return classLevel; + } + if (classLevel.isEmpty()) { + return methodLevel; + } + var merged = new HashMap<>(classLevel); + merged.putAll(methodLevel); + return merged; + } + } +} diff --git a/graphql-apt/src/main/java/feign/graphql/apt/TypeGenerator.java b/graphql-apt/src/main/java/feign/graphql/apt/TypeGenerator.java index ce30adff65..87341b1709 100644 --- a/graphql-apt/src/main/java/feign/graphql/apt/TypeGenerator.java +++ b/graphql-apt/src/main/java/feign/graphql/apt/TypeGenerator.java @@ -22,7 +22,6 @@ import com.squareup.javapoet.TypeSpec; import graphql.language.EnumTypeDefinition; import graphql.language.Field; -import graphql.language.FieldDefinition; import graphql.language.InputObjectTypeDefinition; import graphql.language.ListType; import graphql.language.NonNullType; @@ -38,6 +37,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Queue; import java.util.Set; import java.util.TreeSet; @@ -59,6 +59,7 @@ public class TypeGenerator { private final Set generatedTypes = new HashSet<>(); private final Queue pendingTypes = new ArrayDeque<>(); private final Map resultTypeSignatures = new HashMap<>(); + private TypeAnnotationConfig annotationConfig = TypeAnnotationConfig.EMPTY; public TypeGenerator( Filer filer, @@ -73,6 +74,10 @@ public TypeGenerator( this.targetPackage = targetPackage; } + public void setAnnotationConfig(TypeAnnotationConfig config) { + this.annotationConfig = config; + } + public void generateResultType( String className, SelectionSet selectionSet, @@ -113,7 +118,7 @@ public void generateResultType( } resultTypeSignatures.put(className, new ResultTypeUsage(signature, fields, element)); - var tree = buildResultType(className, selectionSet, parentType); + var tree = buildResultType(className, selectionSet, parentType, ""); if (tree == null) { return; } @@ -123,7 +128,10 @@ public void generateResultType( } private ResultTypeDefinition buildResultType( - String className, SelectionSet selectionSet, ObjectTypeDefinition parentType) { + String className, + SelectionSet selectionSet, + ObjectTypeDefinition parentType, + String pathPrefix) { var fields = new ArrayList(); var innerTypes = new ArrayList(); @@ -132,39 +140,40 @@ private ResultTypeDefinition buildResultType( continue; } var fieldName = field.getName(); - var schemaDef = findFieldDefinition(parentType, fieldName); + var schemaDef = GraphqlTypeMapper.findFieldDefinition(parentType, fieldName); if (schemaDef == null) { continue; } var fieldType = schemaDef.getType(); - var rawTypeName = unwrapTypeName(fieldType); + var rawTypeName = GraphqlTypeMapper.unwrapTypeName(fieldType); if (field.getSelectionSet() != null && !field.getSelectionSet().getSelections().isEmpty()) { var nestedClassName = capitalize(fieldName); + var nestedPath = pathPrefix.isEmpty() ? fieldName : pathPrefix + "." + fieldName; var nestedObjectType = registry.getType(rawTypeName, ObjectTypeDefinition.class).orElse(null); if (nestedObjectType != null) { var innerTree = - buildResultType(nestedClassName, field.getSelectionSet(), nestedObjectType); + buildResultType( + nestedClassName, field.getSelectionSet(), nestedObjectType, nestedPath); if (innerTree != null) { innerTypes.add(innerTree); } } - var nestedType = wrapType(fieldType, ClassName.get("", nestedClassName)); - fields.add(toRecordField(fieldName, nestedType)); + var nestedType = + wrapType(fieldType, ClassName.get("", nestedClassName), annotationConfig.useOptional()); + var fieldNonNull = fieldType instanceof NonNullType; + fields.add(toRecordField(fieldName, nestedType, fieldNonNull)); } else { - var javaType = typeMapper.map(fieldType); - fields.add(toRecordField(fieldName, javaType)); + var javaType = typeMapper.map(fieldType, annotationConfig.useOptional()); + var fieldNonNull = fieldType instanceof NonNullType; + fields.add(toRecordField(fieldName, javaType, fieldNonNull)); enqueueIfNonScalar(rawTypeName); } } - var def = new ResultTypeDefinition(); - def.className = className; - def.fields = fields; - def.innerTypes = innerTypes; - return def; + return new ResultTypeDefinition(className, pathPrefix, fields, innerTypes); } private void writeResultRecord(ResultTypeDefinition tree, Element element) { @@ -179,6 +188,7 @@ private void writeResultRecord(ResultTypeDefinition tree, Element element) { var imports = new TreeSet(); collectAllImports(tree, imports); + imports.addAll(annotationConfig.imports()); if (!imports.isEmpty()) { for (var imp : imports) { out.println("import " + imp + ";"); @@ -199,11 +209,16 @@ private void writeResultRecord(ResultTypeDefinition tree, Element element) { } private void writeRecordBody(PrintWriter out, ResultTypeDefinition tree, String indent) { + var pathPrefix = tree.fieldName; var params = tree.fields.stream() - .map(f -> f.typeString + " " + f.name) + .map(f -> formatFieldParam(f, pathPrefix)) .collect(Collectors.joining(", ")); + for (var annotation : annotationConfig.annotations()) { + out.println(indent + annotation); + } + if (tree.innerTypes.isEmpty()) { out.println(indent + "public record " + tree.className + "(" + params + ") {}"); } else { @@ -280,10 +295,11 @@ public void generateInputType(String className, String graphqlTypeName, Element for (var valueDef : inputDef.getInputValueDefinitions()) { var fieldName = valueDef.getName(); var fieldType = valueDef.getType(); - var javaType = typeMapper.map(fieldType); - fields.add(toRecordField(fieldName, javaType)); + var javaType = typeMapper.map(fieldType, annotationConfig.useOptional()); + var fieldNonNull = fieldType instanceof NonNullType; + fields.add(toRecordField(fieldName, javaType, fieldNonNull)); - var rawTypeName = unwrapTypeName(fieldType); + var rawTypeName = GraphqlTypeMapper.unwrapTypeName(fieldType); enqueueIfNonScalar(rawTypeName); } @@ -343,10 +359,12 @@ private void generateFullObjectType( for (var fieldDef : objectDef.getFieldDefinitions()) { var fieldName = fieldDef.getName(); - var javaType = typeMapper.map(fieldDef.getType()); - fields.add(toRecordField(fieldName, javaType)); + var fieldType = fieldDef.getType(); + var javaType = typeMapper.map(fieldType, annotationConfig.useOptional()); + var fieldNonNull = fieldType instanceof NonNullType; + fields.add(toRecordField(fieldName, javaType, fieldNonNull)); - var rawTypeName = unwrapTypeName(fieldDef.getType()); + var rawTypeName = GraphqlTypeMapper.unwrapTypeName(fieldDef.getType()); enqueueIfNonScalar(rawTypeName); } @@ -360,35 +378,22 @@ private void enqueueIfNonScalar(String typeName) { } } - private FieldDefinition findFieldDefinition(ObjectTypeDefinition typeDef, String fieldName) { - for (var fd : typeDef.getFieldDefinitions()) { - if (fd.getName().equals(fieldName)) { - return fd; - } + private TypeName wrapType(Type schemaType, TypeName innerType, boolean useOptional) { + boolean nullable = !(schemaType instanceof NonNullType); + var wrapped = wrapTypeInner(schemaType, innerType); + if (useOptional && nullable) { + return ParameterizedTypeName.get(ClassName.get(Optional.class), wrapped); } - return null; + return wrapped; } - private String unwrapTypeName(Type type) { - if (type instanceof NonNullType nullType) { - return unwrapTypeName(nullType.getType()); - } - if (type instanceof ListType listType) { - return unwrapTypeName(listType.getType()); - } - if (type instanceof graphql.language.TypeName name) { - return name.getName(); - } - return "String"; - } - - private TypeName wrapType(Type schemaType, TypeName innerType) { + private TypeName wrapTypeInner(Type schemaType, TypeName innerType) { if (schemaType instanceof NonNullType type) { - return wrapType(type.getType(), innerType); + return wrapTypeInner(type.getType(), innerType); } if (schemaType instanceof ListType type) { return ParameterizedTypeName.get( - ClassName.get(List.class), wrapType(type.getType(), innerType)); + ClassName.get(List.class), wrapTypeInner(type.getType(), innerType)); } return innerType; } @@ -414,10 +419,30 @@ private void writeType(TypeSpec typeSpec, Element element) { } } - private RecordField toRecordField(String name, TypeName typeName) { + private String formatFieldParam(RecordField f, String pathPrefix) { + var fieldPath = pathPrefix.isEmpty() ? f.name : pathPrefix + "." + f.name; + var fa = annotationConfig.fieldAnnotations().get(fieldPath); + var hasNonNull = f.nonNull && !annotationConfig.nonNullAnnotations().isEmpty(); + + var typeStr = fa != null && fa.typeOverride() != null ? fa.typeOverride() : f.typeString; + + if (!hasNonNull && (fa == null || fa.annotations().isEmpty())) { + return typeStr + " " + f.name; + } + + var fieldAnns = new ArrayList(); + if (hasNonNull) { + fieldAnns.addAll(annotationConfig.nonNullAnnotations()); + } + if (fa != null) { + fieldAnns.addAll(fa.annotations()); + } + return String.join(" ", fieldAnns) + " " + typeStr + " " + f.name; + } + + private RecordField toRecordField(String name, TypeName typeName, boolean nonNull) { var typeString = typeNameToString(typeName); - var importFqn = resolveImport(typeName); - return new RecordField(typeString, name, importFqn, typeName); + return new RecordField(typeString, name, typeName, nonNull); } private String typeNameToString(TypeName typeName) { @@ -435,20 +460,6 @@ private String typeNameToString(TypeName typeName) { return typeName.toString(); } - private String resolveImport(TypeName typeName) { - if (typeName instanceof ParameterizedTypeName parameterized) { - resolveImport(parameterized.rawType); - for (var typeArg : parameterized.typeArguments) { - resolveImport(typeArg); - } - return fqnIfNeeded(parameterized.rawType); - } - if (typeName instanceof ClassName name) { - return fqnIfNeeded(name); - } - return null; - } - private String fqnIfNeeded(ClassName className) { var pkg = className.packageName(); if (pkg.equals("java.lang") || pkg.equals(targetPackage) || pkg.isEmpty()) { @@ -490,6 +501,7 @@ private void writeRecord(String className, List fields, Element ele } var imports = collectImports(fields); + imports.addAll(annotationConfig.imports()); if (!imports.isEmpty()) { for (var imp : imports) { out.println("import " + imp + ";"); @@ -497,8 +509,12 @@ private void writeRecord(String className, List fields, Element ele out.println(); } + for (var annotation : annotationConfig.annotations()) { + out.println(annotation); + } + var params = - fields.stream().map(f -> f.typeString + " " + f.name).collect(Collectors.joining(", ")); + fields.stream().map(f -> formatFieldParam(f, "")).collect(Collectors.joining(", ")); out.println("public record " + className + "(" + params + ") {}"); } @@ -514,30 +530,11 @@ private void writeRecord(String className, List fields, Element ele record ResultTypeUsage(String signature, String fields, Element element) {} - static class ResultTypeDefinition { - String className; - List fields; - List innerTypes; - } - - static class RecordField { - final String typeString; - final String name; - final String importFqn; - final TypeName typeName; - - RecordField(String typeString, String name, String importFqn) { - this.typeString = typeString; - this.name = name; - this.importFqn = importFqn; - this.typeName = null; - } + record ResultTypeDefinition( + String className, + String fieldName, + List fields, + List innerTypes) {} - RecordField(String typeString, String name, String importFqn, TypeName typeName) { - this.typeString = typeString; - this.name = name; - this.importFqn = importFqn; - this.typeName = typeName; - } - } + record RecordField(String typeString, String name, TypeName typeName, boolean nonNull) {} } diff --git a/graphql-apt/src/test/java/feign/graphql/apt/GraphqlSchemaProcessorTest.java b/graphql-apt/src/test/java/feign/graphql/apt/GraphqlSchemaProcessorTest.java index eee18234ae..180c223ad0 100644 --- a/graphql-apt/src/test/java/feign/graphql/apt/GraphqlSchemaProcessorTest.java +++ b/graphql-apt/src/test/java/feign/graphql/apt/GraphqlSchemaProcessorTest.java @@ -126,7 +126,8 @@ interface NestedApi { assertThat(compilation) .generatedSourceFile("test.CharacterResult") .contentsAsUtf8String() - .contains("public record Location(String planet, String sector, String region) {}"); + .contains( + "public record Location(Optional planet, Optional sector, Optional region) {}"); } @Test @@ -625,10 +626,13 @@ interface InnerApi { assertThat(compilation).generatedSourceFile("test.ShipResult").contentsAsUtf8String(); contents.contains( - "public record ShipResult(String id, String name, Location location, Specs specs)"); - contents.contains("public record Location(String planet, Coordinates coordinates)"); - contents.contains("public record Coordinates(Double latitude, Double longitude) {}"); - contents.contains("public record Specs(Integer lengthMeters, String classification) {}"); + "public record ShipResult(String id, String name, Optional location, Optional specs)"); + contents.contains( + "public record Location(Optional planet, Optional coordinates)"); + contents.contains( + "public record Coordinates(Optional latitude, Optional longitude) {}"); + contents.contains( + "public record Specs(Optional lengthMeters, Optional classification) {}"); } @Test @@ -665,12 +669,12 @@ interface DiffNestedApi { assertThat(compilation) .generatedSourceFile("test.CharByPlanet") .contentsAsUtf8String() - .contains("public record Location(String planet) {}"); + .contains("public record Location(Optional planet) {}"); assertThat(compilation) .generatedSourceFile("test.CharByRegion") .contentsAsUtf8String() - .contains("public record Location(String sector, String region) {}"); + .contains("public record Location(Optional sector, Optional region) {}"); } @Test @@ -848,4 +852,942 @@ mutation create($name: String!) { assertThat(compilation).failed(); assertThat(compilation).hadErrorContaining("email"); } + + @Test + void useOptionalDisabledGeneratesPlainTypes() { + var source = + JavaFileObjects.forSourceString( + "test.NoOptionalApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false) + interface NoOptionalApi { + @GraphqlQuery(\""" + { character(id: "1") { id name email location { planet } } } + \""") + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + var contents = + assertThat(compilation).generatedSourceFile("test.CharResult").contentsAsUtf8String(); + contents.contains( + "public record CharResult(String id, String name, String email, Location location)"); + contents.contains("public record Location(String planet) {}"); + } + + @Test + void useOptionalDefaultWrapsNullableFields() { + var source = + JavaFileObjects.forSourceString( + "test.OptionalApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface OptionalApi { + @GraphqlQuery(\""" + { character(id: "1") { id name email location { planet } } } + \""") + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + var contents = + assertThat(compilation).generatedSourceFile("test.CharResult").contentsAsUtf8String(); + contents.contains("import java.util.Optional;"); + contents.contains( + "String id, String name, Optional email, Optional location"); + contents.contains("public record Location(Optional planet) {}"); + } + + @Test + void useOptionalMethodOverridesClassLevel() { + var source = + JavaFileObjects.forSourceString( + "test.OverrideApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + import feign.graphql.Toggle; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false) + interface OverrideApi { + @GraphqlQuery(value = \""" + { character(id: "1") { id name email } } + \""", useOptional = Toggle.TRUE) + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + var contents = + assertThat(compilation).generatedSourceFile("test.CharResult").contentsAsUtf8String(); + contents.contains("import java.util.Optional;"); + contents.contains("String id, String name, Optional email"); + } + + @Test + void typeAnnotationsAddedToGeneratedRecords() { + var source = + JavaFileObjects.forSourceString( + "test.AnnotatedApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false, + typeAnnotations = {Deprecated.class}) + interface AnnotatedApi { + @GraphqlQuery(\""" + mutation createCharacter($input: CreateCharacterInput!) { + createCharacter(input: $input) { id name } + }\""") + CreateResult createCharacter(CreateCharacterInput input); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + assertThat(compilation) + .generatedSourceFile("test.CreateResult") + .contentsAsUtf8String() + .contains("@Deprecated"); + assertThat(compilation) + .generatedSourceFile("test.CreateCharacterInput") + .contentsAsUtf8String() + .contains("@Deprecated"); + } + + @Test + void rawTypeAnnotationsAppendedToGeneratedRecords() { + var source = + JavaFileObjects.forSourceString( + "test.RawAnnotatedApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false, + rawTypeAnnotations = {"@Deprecated"}) + interface RawAnnotatedApi { + @GraphqlQuery(\""" + { character(id: "1") { id name } } + \""") + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + assertThat(compilation) + .generatedSourceFile("test.CharResult") + .contentsAsUtf8String() + .contains("@Deprecated"); + } + + @Test + void collisionBetweenTypeAndRawAnnotationUsesClassAsImportOnly() { + var source = + JavaFileObjects.forSourceString( + "test.CollisionApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false, + typeAnnotations = {Deprecated.class}, + rawTypeAnnotations = {"@Deprecated(since = \\"1.0\\")"}) + interface CollisionApi { + @GraphqlQuery(\""" + { character(id: "1") { id name } } + \""") + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + var contents = + assertThat(compilation).generatedSourceFile("test.CharResult").contentsAsUtf8String(); + contents.contains("@Deprecated(since = \"1.0\")"); + } + + @Test + void methodLevelAnnotationsOverrideClassLevel() { + var source = + JavaFileObjects.forSourceString( + "test.MethodOverrideApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false, + typeAnnotations = {Deprecated.class}) + interface MethodOverrideApi { + @GraphqlQuery(value = \""" + { character(id: "1") { id name } } + \""", rawTypeAnnotations = {"@SuppressWarnings(\\"unchecked\\")"}) + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + var contents = + assertThat(compilation).generatedSourceFile("test.CharResult").contentsAsUtf8String(); + contents.contains("@SuppressWarnings(\"unchecked\")"); + } + + @Test + void optionalOnInputTypeWrapsNullableFields() { + var source = + JavaFileObjects.forSourceString( + "test.OptionalInputApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface OptionalInputApi { + @GraphqlQuery(\""" + mutation createCharacter($input: CreateCharacterInput!) { + createCharacter(input: $input) { id } + }\""") + Object createCharacter(CreateCharacterInput input); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + var contents = + assertThat(compilation) + .generatedSourceFile("test.CreateCharacterInput") + .contentsAsUtf8String(); + contents.contains("String name, String email"); + contents.contains("Optional appearsIn"); + contents.contains("Optional location"); + contents.contains("Optional> tags"); + contents.contains("Optional starshipId"); + } + + @Test + void mixedTypeAndRawAnnotationsWithoutCollision() { + var source = + JavaFileObjects.forSourceString( + "test.MixedApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false, + typeAnnotations = {Deprecated.class}, + rawTypeAnnotations = {"@SuppressWarnings(\\"unchecked\\")"}) + interface MixedApi { + @GraphqlQuery(\""" + { character(id: "1") { id name } } + \""") + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + var contents = + assertThat(compilation).generatedSourceFile("test.CharResult").contentsAsUtf8String(); + contents.contains("@Deprecated"); + contents.contains("@SuppressWarnings(\"unchecked\")"); + } + + @Test + void annotationsAppliedToNestedResultRecords() { + var source = + JavaFileObjects.forSourceString( + "test.NestedAnnotatedApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false, + typeAnnotations = {Deprecated.class}) + interface NestedAnnotatedApi { + @GraphqlQuery(\""" + { + starship(id: "1") { + id name + location { planet coordinates { latitude longitude } } + } + }\""") + ShipResult getShip(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + var contents = + assertThat(compilation).generatedSourceFile("test.ShipResult").contentsAsUtf8String(); + contents.contains("@Deprecated\npublic record ShipResult("); + contents.contains("@Deprecated\n public record Location("); + contents.contains("@Deprecated\n public record Coordinates("); + } + + @Test + void fieldAnnotationOnSimpleField() { + var source = + JavaFileObjects.forSourceString( + "test.FieldAnnotApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + import feign.graphql.GraphqlField; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false) + interface FieldAnnotApi { + @GraphqlQuery(\""" + mutation createCharacter($input: CreateCharacterInput!) { + createCharacter(input: $input) { id name email } + }\""") + @GraphqlField(name = "email", typeAnnotations = {Deprecated.class}) + CreateResult createCharacter(CreateCharacterInput input); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + assertThat(compilation) + .generatedSourceFile("test.CreateResult") + .contentsAsUtf8String() + .contains("String id, String name, @Deprecated String email"); + } + + @Test + void fieldAnnotationWithRawString() { + var source = + JavaFileObjects.forSourceString( + "test.FieldRawApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + import feign.graphql.GraphqlField; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false) + interface FieldRawApi { + @GraphqlQuery(\""" + { character(id: "1") { id name email } } + \""") + @GraphqlField(name = "name", rawTypeAnnotations = {"@SuppressWarnings(\\"unchecked\\")"}) + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + assertThat(compilation) + .generatedSourceFile("test.CharResult") + .contentsAsUtf8String() + .contains("@SuppressWarnings(\"unchecked\") String name"); + } + + @Test + void fieldAnnotationWithDotNotationForNestedField() { + var source = + JavaFileObjects.forSourceString( + "test.DotNotationApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + import feign.graphql.GraphqlField; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false) + interface DotNotationApi { + @GraphqlQuery(\""" + { + character(id: "1") { + id name + location { planet sector } + } + }\""") + @GraphqlField(name = "location.planet", typeAnnotations = {Deprecated.class}) + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + assertThat(compilation) + .generatedSourceFile("test.CharResult") + .contentsAsUtf8String() + .contains("@Deprecated String planet, String sector"); + } + + @Test + void fieldAnnotationCollisionUsesClassAsImportOnly() { + var source = + JavaFileObjects.forSourceString( + "test.FieldCollisionApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + import feign.graphql.GraphqlField; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false) + interface FieldCollisionApi { + @GraphqlQuery(\""" + { character(id: "1") { id name } } + \""") + @GraphqlField(name = "name", + typeAnnotations = {Deprecated.class}, + rawTypeAnnotations = {"@Deprecated(since = \\"2.0\\")"}) + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + assertThat(compilation) + .generatedSourceFile("test.CharResult") + .contentsAsUtf8String() + .contains("@Deprecated(since = \"2.0\") String name"); + } + + @Test + void multipleFieldAnnotations() { + var source = + JavaFileObjects.forSourceString( + "test.MultiFieldApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + import feign.graphql.GraphqlField; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false) + interface MultiFieldApi { + @GraphqlQuery(\""" + { character(id: "1") { id name email } } + \""") + @GraphqlField(name = "name", typeAnnotations = {Deprecated.class}) + @GraphqlField(name = "email", typeAnnotations = {Deprecated.class}) + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + var contents = + assertThat(compilation).generatedSourceFile("test.CharResult").contentsAsUtf8String(); + contents.contains("@Deprecated String name"); + contents.contains("@Deprecated String email"); + } + + @Test + void classLevelTypeAnnotationsWithMethodLevelFieldAnnotations() { + var source = + JavaFileObjects.forSourceString( + "test.ClassAndFieldApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + import feign.graphql.GraphqlField; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false, + typeAnnotations = {Deprecated.class}) + interface ClassAndFieldApi { + @GraphqlQuery(\""" + { character(id: "1") { id name email } } + \""") + @GraphqlField(name = "email", rawTypeAnnotations = {"@SuppressWarnings(\\"unchecked\\")"}) + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + var contents = + assertThat(compilation).generatedSourceFile("test.CharResult").contentsAsUtf8String(); + contents.contains("@Deprecated\npublic record CharResult("); + contents.contains("@SuppressWarnings(\"unchecked\") String email"); + } + + @Test + void deepNestedDotNotation() { + var source = + JavaFileObjects.forSourceString( + "test.DeepDotApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + import feign.graphql.GraphqlField; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false) + interface DeepDotApi { + @GraphqlQuery(\""" + { + starship(id: "1") { + id name + location { planet coordinates { latitude longitude } } + } + }\""") + @GraphqlField(name = "location.coordinates.latitude", typeAnnotations = {Deprecated.class}) + ShipResult getShip(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + assertThat(compilation) + .generatedSourceFile("test.ShipResult") + .contentsAsUtf8String() + .contains("@Deprecated Double latitude, Double longitude"); + } + + @Test + void usesAddsImportsForRawTypeAnnotations() { + var source = + JavaFileObjects.forSourceString( + "test.UsesApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false, + uses = {Deprecated.class}, + rawTypeAnnotations = {"@Deprecated(since = \\"1.0\\")"}) + interface UsesApi { + @GraphqlQuery(\""" + { character(id: "1") { id name } } + \""") + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + assertThat(compilation) + .generatedSourceFile("test.CharResult") + .contentsAsUtf8String() + .contains("@Deprecated(since = \"1.0\")"); + } + + @Test + void usesAddsImportsForRawFieldAnnotations() { + var source = + JavaFileObjects.forSourceString( + "test.UsesFieldApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + import feign.graphql.GraphqlField; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false, + uses = {Deprecated.class}) + interface UsesFieldApi { + @GraphqlQuery(\""" + { character(id: "1") { id name email } } + \""") + @GraphqlField(name = "email", rawTypeAnnotations = {"@Deprecated(since = \\"2.0\\")"}) + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + var contents = + assertThat(compilation).generatedSourceFile("test.CharResult").contentsAsUtf8String(); + contents.contains("@Deprecated(since = \"2.0\") String email"); + } + + @Test + void fieldTypeOverride() { + var source = + JavaFileObjects.forSourceString( + "test.TypeOverrideApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + import feign.graphql.GraphqlField; + import java.time.ZonedDateTime; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false) + interface TypeOverrideApi { + @GraphqlQuery(\""" + { character(id: "1") { id name email } } + \""") + @GraphqlField(name = "email", type = ZonedDateTime.class) + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + var contents = + assertThat(compilation).generatedSourceFile("test.CharResult").contentsAsUtf8String(); + contents.contains("import java.time.ZonedDateTime;"); + contents.contains("String id, String name, ZonedDateTime email"); + } + + @Test + void fieldTypeOverrideWithAnnotations() { + var source = + JavaFileObjects.forSourceString( + "test.TypeOverrideAnnotApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + import feign.graphql.GraphqlField; + import java.time.ZonedDateTime; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false) + interface TypeOverrideAnnotApi { + @GraphqlQuery(\""" + { character(id: "1") { id name email } } + \""") + @GraphqlField(name = "email", type = ZonedDateTime.class, typeAnnotations = {Deprecated.class}) + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + assertThat(compilation) + .generatedSourceFile("test.CharResult") + .contentsAsUtf8String() + .contains("@Deprecated ZonedDateTime email"); + } + + @Test + void fieldTypeOverrideOnNestedField() { + var source = + JavaFileObjects.forSourceString( + "test.NestedTypeOverrideApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + import feign.graphql.GraphqlField; + import java.math.BigDecimal; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false) + interface NestedTypeOverrideApi { + @GraphqlQuery(\""" + { + character(id: "1") { + id name + location { planet coordinates { latitude longitude } } + } + }\""") + @GraphqlField(name = "location.coordinates.latitude", type = BigDecimal.class) + @GraphqlField(name = "location.coordinates.longitude", type = BigDecimal.class) + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + var contents = + assertThat(compilation).generatedSourceFile("test.CharResult").contentsAsUtf8String(); + contents.contains("import java.math.BigDecimal;"); + contents.contains("BigDecimal latitude, BigDecimal longitude"); + } + + @Test + void classLevelFieldTypeOverrideAppliesToAllMethods() { + var source = + JavaFileObjects.forSourceString( + "test.ClassFieldOverrideApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + import feign.graphql.GraphqlField; + import java.time.ZonedDateTime; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false) + @GraphqlField(name = "email", type = ZonedDateTime.class) + interface ClassFieldOverrideApi { + @GraphqlQuery(\""" + { character(id: "1") { id name email } } + \""") + CharResult1 getCharacter1(); + + @GraphqlQuery(\""" + { character(id: "2") { id email } } + \""") + CharResult2 getCharacter2(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + assertThat(compilation) + .generatedSourceFile("test.CharResult1") + .contentsAsUtf8String() + .contains("ZonedDateTime email"); + assertThat(compilation) + .generatedSourceFile("test.CharResult2") + .contentsAsUtf8String() + .contains("ZonedDateTime email"); + } + + @Test + void methodLevelFieldOverridesClassLevel() { + var source = + JavaFileObjects.forSourceString( + "test.MethodOverridesClassFieldApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + import feign.graphql.GraphqlField; + import java.time.ZonedDateTime; + import java.time.Instant; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false) + @GraphqlField(name = "email", type = ZonedDateTime.class) + interface MethodOverridesClassFieldApi { + @GraphqlQuery(\""" + { character(id: "1") { id name email } } + \""") + @GraphqlField(name = "email", type = Instant.class) + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + assertThat(compilation) + .generatedSourceFile("test.CharResult") + .contentsAsUtf8String() + .contains("Instant email"); + } + + @Test + void nonNullAnnotationsAppliedToRequiredFields() { + var source = + JavaFileObjects.forSourceString( + "test.NonNullApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false, + nonNullTypeAnnotations = {Deprecated.class}) + interface NonNullApi { + @GraphqlQuery(\""" + { character(id: "1") { id name email } } + \""") + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + var contents = + assertThat(compilation).generatedSourceFile("test.CharResult").contentsAsUtf8String(); + contents.contains("@Deprecated String id, @Deprecated String name, String email"); + } + + @Test + void nonNullAnnotationsOnInputType() { + var source = + JavaFileObjects.forSourceString( + "test.NonNullInputApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false, + nonNullTypeAnnotations = {Deprecated.class}) + interface NonNullInputApi { + @GraphqlQuery(\""" + mutation createCharacter($input: CreateCharacterInput!) { + createCharacter(input: $input) { id } + }\""") + Object createCharacter(CreateCharacterInput input); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + var contents = + assertThat(compilation) + .generatedSourceFile("test.CreateCharacterInput") + .contentsAsUtf8String(); + contents.contains("@Deprecated String name, @Deprecated String email"); + contents.contains("Episode appearsIn"); + } + + @Test + void nonNullRawAnnotations() { + var source = + JavaFileObjects.forSourceString( + "test.NonNullRawApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false, + nonNullRawTypeAnnotations = {"@SuppressWarnings(\\"required\\")"}) + interface NonNullRawApi { + @GraphqlQuery(\""" + { character(id: "1") { id name email } } + \""") + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + var contents = + assertThat(compilation).generatedSourceFile("test.CharResult").contentsAsUtf8String(); + contents.contains("@SuppressWarnings(\"required\") String id"); + contents.contains("String email"); + } + + @Test + void nonNullAnnotationsCombinedWithFieldAnnotations() { + var source = + JavaFileObjects.forSourceString( + "test.NonNullFieldComboApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + import feign.graphql.GraphqlField; + import java.time.ZonedDateTime; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false, + nonNullTypeAnnotations = {Deprecated.class}) + interface NonNullFieldComboApi { + @GraphqlQuery(\""" + { character(id: "1") { id name email } } + \""") + @GraphqlField(name = "name", type = ZonedDateTime.class) + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + var contents = + assertThat(compilation).generatedSourceFile("test.CharResult").contentsAsUtf8String(); + contents.contains("@Deprecated ZonedDateTime name"); + contents.contains("@Deprecated String id"); + contents.contains("String email"); + } } diff --git a/graphql/README.md b/graphql/README.md index 036540e33a..aefed1c47a 100644 --- a/graphql/README.md +++ b/graphql/README.md @@ -164,6 +164,144 @@ Returns `Optional.empty()` when the data is null or missing, and `Optional.of(va Optional findTopUser(int limit); ``` +## Optional Fields + +By default, nullable GraphQL fields (without `!`) are wrapped in `Optional<>` in generated records: + +```graphql +type User { + id: ID! # non-null + name: String! # non-null + email: String # nullable +} +``` + +```java +public record User(String id, String name, Optional email) {} +``` + +This is controlled by `useOptional` on `@GraphqlSchema` (defaults to `true`): + +```java +@GraphqlSchema(value = "schema.graphql", useOptional = false) +``` + +Override per method with `Toggle`: + +```java +@GraphqlQuery(value = "...", useOptional = Toggle.FALSE) +``` + +## Type Annotations on Generated Records + +Add annotations to all generated records using `typeAnnotations` (no-arg) and `rawTypeAnnotations` (with args): + +```java +@GraphqlSchema( + value = "schema.graphql", + typeAnnotations = {Builder.class, Jacksonized.class}, + rawTypeAnnotations = {"@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)"} +) +``` + +Generates: + +```java +@Builder +@Jacksonized +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record User(String id, String name) {} +``` + +**Collision rule:** when the same annotation simple name appears in both `typeAnnotations` and `rawTypeAnnotations`, the class provides only the import and the raw string is used: + +```java +typeAnnotations = {Builder.class}, +rawTypeAnnotations = {"@Builder(toBuilder = true)"} +// Result: import lombok.Builder; + @Builder(toBuilder = true) +``` + +Override per method on `@GraphqlQuery` — non-empty arrays replace class-level values. + +## Import-Only Classes with `uses` + +When raw annotations reference classes not in `typeAnnotations`, use `uses` to add their imports: + +```java +@GraphqlSchema( + value = "schema.graphql", + uses = {Min.class, Max.class, Pattern.class} +) +``` + +These classes are added as imports to all generated files but no annotations are generated from them. + +## Non-Null Field Annotations + +Automatically annotate all non-null (`!`) fields with `nonNullTypeAnnotations`: + +```java +@GraphqlSchema( + value = "schema.graphql", + nonNullTypeAnnotations = {NotNull.class} +) +``` + +For `name: String!` and `email: String`, generates: + +```java +public record User(@NotNull String name, Optional email) {} +``` + +Same collision rule applies with `nonNullRawTypeAnnotations`. Overridable per method on `@GraphqlQuery`. + +## Field-Level Annotations with `@GraphqlField` + +Apply annotations or override types on specific fields. Repeatable, works on both the interface (class-level default) and individual methods: + +```java +@GraphqlSchema(value = "schema.graphql", useOptional = false) +@GraphqlField(name = "email", typeAnnotations = {Email.class}) +interface UserApi { + + @GraphqlQuery("{ user(id: \"1\") { id name email } }") + @GraphqlField(name = "name", typeAnnotations = {NotBlank.class}) + UserResult getUser(); +} +``` + +Generates: + +```java +public record UserResult(String id, @NotBlank String name, @Email String email) {} +``` + +### Dot Notation for Nested Fields + +Use dot notation to target fields in nested records: + +```java +@GraphqlField(name = "location.coordinates.latitude", typeAnnotations = {NotNull.class}) +@GraphqlField(name = "location.planet", typeAnnotations = {NotBlank.class}) +``` + +### Field Type Override + +Override the Java type for a field — useful when APIs return strings for dates without declaring custom scalars: + +```java +@GraphqlField(name = "createdAt", type = ZonedDateTime.class) +@GraphqlField(name = "amount", type = BigDecimal.class) +``` + +Combines with annotations: + +```java +@GraphqlField(name = "createdAt", type = ZonedDateTime.class, typeAnnotations = {NotNull.class}) +``` + +Class-level `@GraphqlField` applies to all methods; method-level overrides for the same field name. + ## Disabling Type Generation If you provide your own model classes, disable automatic generation: diff --git a/graphql/src/main/java/feign/graphql/GraphqlField.java b/graphql/src/main/java/feign/graphql/GraphqlField.java new file mode 100644 index 0000000000..76f3261798 --- /dev/null +++ b/graphql/src/main/java/feign/graphql/GraphqlField.java @@ -0,0 +1,38 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.graphql; + +import feign.Experimental; +import java.lang.annotation.ElementType; +import java.lang.annotation.Repeatable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Experimental +@Retention(RetentionPolicy.SOURCE) +@Target({ElementType.TYPE, ElementType.METHOD}) +@Repeatable(GraphqlFields.class) +public @interface GraphqlField { + + String name(); + + Class type() default Void.class; + + Class[] typeAnnotations() default {}; + + String[] rawTypeAnnotations() default {}; +} diff --git a/graphql/src/main/java/feign/graphql/GraphqlFields.java b/graphql/src/main/java/feign/graphql/GraphqlFields.java new file mode 100644 index 0000000000..c7652f76d7 --- /dev/null +++ b/graphql/src/main/java/feign/graphql/GraphqlFields.java @@ -0,0 +1,30 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.graphql; + +import feign.Experimental; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Experimental +@Retention(RetentionPolicy.SOURCE) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface GraphqlFields { + + GraphqlField[] value(); +} diff --git a/graphql/src/main/java/feign/graphql/GraphqlQuery.java b/graphql/src/main/java/feign/graphql/GraphqlQuery.java index b02987e34b..4cc896d6d0 100644 --- a/graphql/src/main/java/feign/graphql/GraphqlQuery.java +++ b/graphql/src/main/java/feign/graphql/GraphqlQuery.java @@ -27,4 +27,14 @@ public @interface GraphqlQuery { String value(); + + Toggle useOptional() default Toggle.INHERIT; + + Class[] typeAnnotations() default {}; + + String[] rawTypeAnnotations() default {}; + + Class[] nonNullTypeAnnotations() default {}; + + String[] nonNullRawTypeAnnotations() default {}; } diff --git a/graphql/src/main/java/feign/graphql/GraphqlSchema.java b/graphql/src/main/java/feign/graphql/GraphqlSchema.java index 695ae21ff2..5d97e983c4 100644 --- a/graphql/src/main/java/feign/graphql/GraphqlSchema.java +++ b/graphql/src/main/java/feign/graphql/GraphqlSchema.java @@ -29,4 +29,16 @@ String value(); boolean generateTypes() default true; + + boolean useOptional() default true; + + Class[] uses() default {}; + + Class[] typeAnnotations() default {}; + + String[] rawTypeAnnotations() default {}; + + Class[] nonNullTypeAnnotations() default {}; + + String[] nonNullRawTypeAnnotations() default {}; } diff --git a/graphql/src/main/java/feign/graphql/Toggle.java b/graphql/src/main/java/feign/graphql/Toggle.java new file mode 100644 index 0000000000..9dd7b6957d --- /dev/null +++ b/graphql/src/main/java/feign/graphql/Toggle.java @@ -0,0 +1,22 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.graphql; + +public enum Toggle { + INHERIT, + TRUE, + FALSE +}