From a4292613ee0be4feabf3687ce91daec921777edb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20W=C3=B6ckinger?= Date: Fri, 3 Jul 2026 09:57:15 +0200 Subject: [PATCH] Add support @Target annotation Introduce @Target annotation, so GraphQL input types can be extended like GraphQL types using @Source annotation --- .gitignore | 1 + .../smallrye/graphql/schema/Annotations.java | 1 + .../graphql/schema/SchemaBuilder.java | 2 +- .../schema/creator/ArgumentCreator.java | 8 +- .../schema/creator/OperationCreator.java | 89 ++++++++++++++++++ .../schema/creator/type/InputTypeCreator.java | 41 ++++++++- .../schema/helper/TargetOperationHelper.java | 55 ++++++++++++ .../graphql/index/SchemaBuilderTest.java | 18 ++++ .../graphql/schema/model/InputType.java | 26 +++++- .../graphql/schema/model/Operation.java | 48 +++++++++- docs/target-for-input-types.md | 65 ++++++++++++++ mkdocs.yml | 1 + .../java/io/smallrye/graphql/api/Target.java | 55 ++++++++++++ .../smallrye/graphql/bootstrap/Bootstrap.java | 18 +++- .../datafetcher/helper/ArgumentHelper.java | 62 ++++++++++++- .../graphql/json/InputFieldsInfo.java | 16 ++++ .../graphql/execution/TargetTest.java | 90 +++++++++++++++++++ .../smallrye/graphql/schema/SchemaTest.java | 16 ++++ .../schema/TargetInputFieldTestApi.java | 32 +++++++ .../graphql/test/targetinput/TargetApi.java | 34 +++++++ .../graphql/test/targetinput/TargetInput.java | 6 ++ 21 files changed, 671 insertions(+), 13 deletions(-) create mode 100644 common/schema-builder/src/main/java/io/smallrye/graphql/schema/helper/TargetOperationHelper.java create mode 100644 docs/target-for-input-types.md create mode 100644 server/api/src/main/java/io/smallrye/graphql/api/Target.java create mode 100644 server/implementation/src/test/java/io/smallrye/graphql/execution/TargetTest.java create mode 100644 server/implementation/src/test/java/io/smallrye/graphql/schema/TargetInputFieldTestApi.java create mode 100644 server/implementation/src/test/java/io/smallrye/graphql/test/targetinput/TargetApi.java create mode 100644 server/implementation/src/test/java/io/smallrye/graphql/test/targetinput/TargetInput.java diff --git a/.gitignore b/.gitignore index 49dee7762..8e1233613 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ formatter-maven-cache.properties # ignore Gradle generated build folders build +bin # ignore downloaded maven /tools/maven diff --git a/common/schema-builder/src/main/java/io/smallrye/graphql/schema/Annotations.java b/common/schema-builder/src/main/java/io/smallrye/graphql/schema/Annotations.java index 96ea84b9b..256098f63 100644 --- a/common/schema-builder/src/main/java/io/smallrye/graphql/schema/Annotations.java +++ b/common/schema-builder/src/main/java/io/smallrye/graphql/schema/Annotations.java @@ -593,6 +593,7 @@ private static Map getAnnotationsWithFilter(org.jbo public static final DotName DATAFETCHER = DotName.createSimple("io.smallrye.graphql.api.DataFetcher"); public static final DotName SUBCRIPTION = DotName.createSimple("io.smallrye.graphql.api.Subscription"); public static final DotName RESOLVER = DotName.createSimple("io.smallrye.graphql.api.federation.Resolver"); + public static final DotName TARGET = DotName.createSimple("io.smallrye.graphql.api.Target"); public static final DotName DIRECTIVE = DotName.createSimple("io.smallrye.graphql.api.Directive"); public static final DotName DEFAULT_NON_NULL = DotName.createSimple("io.smallrye.graphql.api.DefaultNonNull"); public static final DotName NULLABLE = DotName.createSimple("io.smallrye.graphql.api.Nullable"); diff --git a/common/schema-builder/src/main/java/io/smallrye/graphql/schema/SchemaBuilder.java b/common/schema-builder/src/main/java/io/smallrye/graphql/schema/SchemaBuilder.java index c57b113c7..f6e785afd 100644 --- a/common/schema-builder/src/main/java/io/smallrye/graphql/schema/SchemaBuilder.java +++ b/common/schema-builder/src/main/java/io/smallrye/graphql/schema/SchemaBuilder.java @@ -114,8 +114,8 @@ private SchemaBuilder(TypeAutoNameStrategy autoNameStrategy) { referenceCreator = new ReferenceCreator(autoNameStrategy); fieldCreator = new FieldCreator(referenceCreator); argumentCreator = new ArgumentCreator(referenceCreator); - inputTypeCreator = new InputTypeCreator(fieldCreator); operationCreator = new OperationCreator(referenceCreator, argumentCreator); + inputTypeCreator = new InputTypeCreator(fieldCreator, operationCreator); typeCreator = new TypeCreator(referenceCreator, fieldCreator, operationCreator); interfaceCreator = new InterfaceCreator(referenceCreator, fieldCreator, operationCreator); directiveTypeCreator = new DirectiveTypeCreator(referenceCreator); diff --git a/common/schema-builder/src/main/java/io/smallrye/graphql/schema/creator/ArgumentCreator.java b/common/schema-builder/src/main/java/io/smallrye/graphql/schema/creator/ArgumentCreator.java index 941848a91..528fb8c23 100644 --- a/common/schema-builder/src/main/java/io/smallrye/graphql/schema/creator/ArgumentCreator.java +++ b/common/schema-builder/src/main/java/io/smallrye/graphql/schema/creator/ArgumentCreator.java @@ -75,7 +75,9 @@ public Optional createArgument(Operation operation, MethodInfo methodI .orElse(defaultName); Reference reference; - if (isSourceAnnotationOnSourceOperation(annotationsForThisArgument, operation)) { + boolean sourceArgument = isSourceAnnotationOnSourceOperation(annotationsForThisArgument, operation); + + if (sourceArgument) { reference = referenceCreator.createReferenceForSourceArgument(argumentType, annotationsForThisArgument); } else if (!argumentType.name().equals(CONTEXT)) { reference = referenceCreator.createReferenceForOperationArgument(argumentType, annotationsForThisArgument); @@ -89,9 +91,7 @@ public Optional createArgument(Operation operation, MethodInfo methodI name, reference); - if (isSourceAnnotationOnSourceOperation(annotationsForThisArgument, operation)) { - argument.setSourceArgument(true); - } + argument.setSourceArgument(sourceArgument); if (validationHelper != null) { List constraintDirectives = validationHelper diff --git a/common/schema-builder/src/main/java/io/smallrye/graphql/schema/creator/OperationCreator.java b/common/schema-builder/src/main/java/io/smallrye/graphql/schema/creator/OperationCreator.java index 3ac0d66fe..ba16b0d0d 100644 --- a/common/schema-builder/src/main/java/io/smallrye/graphql/schema/creator/OperationCreator.java +++ b/common/schema-builder/src/main/java/io/smallrye/graphql/schema/creator/OperationCreator.java @@ -1,6 +1,7 @@ package io.smallrye.graphql.schema.creator; import java.lang.reflect.Modifier; +import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Optional; @@ -17,6 +18,7 @@ import io.smallrye.graphql.schema.Classes; import io.smallrye.graphql.schema.SchemaBuilderException; import io.smallrye.graphql.schema.helper.DeprecatedDirectivesHelper; +import io.smallrye.graphql.schema.helper.DescriptionHelper; import io.smallrye.graphql.schema.helper.Direction; import io.smallrye.graphql.schema.helper.MethodHelper; import io.smallrye.graphql.schema.helper.RolesAllowedDirectivesHelper; @@ -25,6 +27,7 @@ import io.smallrye.graphql.schema.model.Operation; import io.smallrye.graphql.schema.model.OperationType; import io.smallrye.graphql.schema.model.Reference; +import io.smallrye.graphql.schema.model.Wrapper; import kotlin.metadata.Attributes; import kotlin.metadata.KmClassifier; import kotlin.metadata.KmFunction; @@ -113,6 +116,45 @@ public Operation createOperation(MethodInfo methodInfo, OperationType operationT return operation; } + /** + * Creates an operation that is exposed as an input field by @Target. + */ + public Operation createTargetOperation(MethodInfo methodInfo, Reference targetFieldOn) { + if (!Modifier.isPublic(methodInfo.flags())) { + throw new IllegalArgumentException( + "Method " + methodInfo.declaringClass().name().toString() + "#" + methodInfo.name() + + " is used as a target input field, but is not public"); + } + + short[] parameterPositions = getTargetParameterPositions(methodInfo); + short targetPosition = parameterPositions[0]; + short valuePosition = parameterPositions[1]; + + Type valueType = methodInfo.parameterType(valuePosition); + Annotations annotationsForMethod = Annotations.getAnnotationsForMethod(methodInfo); + Annotations annotationsForValue = Annotations.getAnnotationsForArgument(methodInfo, valuePosition); + + String name = getTargetFieldName(methodInfo, annotationsForMethod); + Reference reference = referenceCreator.createReferenceForOperationArgument(valueType, annotationsForValue); + + Operation operation = new Operation(methodInfo.declaringClass().name().toString(), + methodInfo.name(), + name, + name, + reference, + null, + null); + operation.setTargetFieldOn(targetFieldOn); + operation.setParameterClassNames(getParameterClassNames(methodInfo)); + operation.setTargetParameterPosition(targetPosition); + operation.setValueParameterPosition(valuePosition); + + populateField(Direction.IN, operation, valueType, annotationsForValue); + DescriptionHelper.getDescriptionForField(annotationsForMethod, valueType).ifPresent(operation::setDescription); + + return operation; + } + // If the operation return type is a wrapper and is written in Kotlin, // this checks whether the wrapped type is nullable. // Nullability metadata is stored in the kotlin.Metadata annotation @@ -294,6 +336,51 @@ private static String getOperationName(MethodInfo methodInfo, OperationType oper } + private short[] getTargetParameterPositions(MethodInfo methodInfo) { + short targetPosition = -1; + short valuePosition = -1; + int targetCount = 0; + int valueCount = 0; + for (short i = 0; i < methodInfo.parametersCount(); i++) { + Annotations annotations = Annotations.getAnnotationsForArgument(methodInfo, i); + if (annotations.containsOneOfTheseAnnotations(Annotations.TARGET)) { + targetPosition = i; + targetCount++; + } else if (!methodInfo.parameterType(i).name().equals(CONTEXT)) { + valuePosition = i; + valueCount++; + } + } + if (targetCount != 1 || valueCount != 1) { + throw new SchemaBuilderException("Target input field method [" + methodInfo.declaringClass().name() + + "#" + methodInfo.name() + "] must declare exactly one @Target parameter and one input value parameter"); + } + return new short[] { targetPosition, valuePosition }; + } + + private List getParameterClassNames(MethodInfo methodInfo) { + List parameterClassNames = new ArrayList<>(); + for (Type parameterType : methodInfo.parameterTypes()) { + parameterClassNames.add(getParameterClassName(parameterType)); + } + return parameterClassNames; + } + + private String getParameterClassName(Type parameterType) { + return WrapperCreator.createWrapper(parameterType) + .map(Wrapper::getWrapperClassName) + .orElse(parameterType.name().toString()); + } + + private static String getTargetFieldName(MethodInfo methodInfo, Annotations annotations) { + return annotations.getOneOfTheseMethodAnnotationsValue( + Annotations.NAME, + Annotations.JAKARTA_JSONB_PROPERTY, + Annotations.JAVAX_JSONB_PROPERTY, + Annotations.JACKSON_PROPERTY) + .orElse(methodInfo.name()); + } + private static DotName getOperationAnnotation(OperationType operationType) { switch (operationType) { case QUERY: @@ -372,4 +459,6 @@ private void addDirectiveForDeprecated(Annotations annotationsForOperation, Oper public String getDirectiveLocation() { return "FIELD_DEFINITION"; } + + private static final DotName CONTEXT = DotName.createSimple("io.smallrye.graphql.api.Context"); } diff --git a/common/schema-builder/src/main/java/io/smallrye/graphql/schema/creator/type/InputTypeCreator.java b/common/schema-builder/src/main/java/io/smallrye/graphql/schema/creator/type/InputTypeCreator.java index 6a735fb60..2386b256f 100644 --- a/common/schema-builder/src/main/java/io/smallrye/graphql/schema/creator/type/InputTypeCreator.java +++ b/common/schema-builder/src/main/java/io/smallrye/graphql/schema/creator/type/InputTypeCreator.java @@ -8,22 +8,28 @@ import java.util.Optional; import org.jboss.jandex.ClassInfo; +import org.jboss.jandex.DotName; import org.jboss.jandex.FieldInfo; import org.jboss.jandex.MethodInfo; +import org.jboss.jandex.MethodParameterInfo; import org.jboss.logging.Logger; import io.smallrye.graphql.schema.Annotations; import io.smallrye.graphql.schema.Classes; import io.smallrye.graphql.schema.ScanningContext; +import io.smallrye.graphql.schema.SchemaBuilderException; import io.smallrye.graphql.schema.creator.FieldCreator; +import io.smallrye.graphql.schema.creator.OperationCreator; import io.smallrye.graphql.schema.helper.DescriptionHelper; import io.smallrye.graphql.schema.helper.Direction; import io.smallrye.graphql.schema.helper.Directives; import io.smallrye.graphql.schema.helper.MethodHelper; +import io.smallrye.graphql.schema.helper.TargetOperationHelper; import io.smallrye.graphql.schema.helper.TypeNameHelper; import io.smallrye.graphql.schema.model.DirectiveInstance; import io.smallrye.graphql.schema.model.Field; import io.smallrye.graphql.schema.model.InputType; +import io.smallrye.graphql.schema.model.Operation; import io.smallrye.graphql.schema.model.Reference; import io.smallrye.graphql.schema.model.ReferenceType; @@ -38,10 +44,12 @@ public class InputTypeCreator implements Creator { private static final Logger LOG = Logger.getLogger(InputTypeCreator.class.getName()); private final FieldCreator fieldCreator; + private final OperationCreator operationCreator; private Directives directives; - public InputTypeCreator(FieldCreator fieldCreator) { + public InputTypeCreator(FieldCreator fieldCreator, OperationCreator operationCreator) { this.fieldCreator = fieldCreator; + this.operationCreator = operationCreator; } @Override @@ -186,6 +194,37 @@ private void addFields(InputType inputType, ClassInfo classInfo, Reference refer } } + addTargetFields(inputType, classInfo); + } + + private void addTargetFields(InputType inputType, ClassInfo classInfo) { + TargetOperationHelper targetOperationHelper = new TargetOperationHelper(); + Map> targetFields = targetOperationHelper.getTargetAnnotations(); + if (!targetFields.containsKey(classInfo.name())) { + return; + } + + List methodParameterInfos = targetFields.get(classInfo.name()); + for (MethodParameterInfo methodParameterInfo : methodParameterInfos) { + MethodInfo methodInfo = methodParameterInfo.method(); + Operation targetOperation = operationCreator.createTargetOperation(methodInfo, inputType); + validateTargetFieldName(inputType, targetOperation); + inputType.addTargetField(targetOperation); + } + } + + private void validateTargetFieldName(InputType inputType, Operation targetOperation) { + if (inputType.hasField(targetOperation.getName())) { + throw new SchemaBuilderException(String.format("Input type '%s' already contains field named '%s'" + + " so target input field, with the same name, cannot be applied. You can resolve this conflict using @Name on the target method.", + inputType.getName(), + targetOperation.getName())); + } + if (inputType.hasTargetField(targetOperation.getName())) { + throw new SchemaBuilderException(String.format("Input type '%s' already contains target input field named '%s'", + inputType.getName(), + targetOperation.getName())); + } } private static final String JAVA_DOT = "java."; diff --git a/common/schema-builder/src/main/java/io/smallrye/graphql/schema/helper/TargetOperationHelper.java b/common/schema-builder/src/main/java/io/smallrye/graphql/schema/helper/TargetOperationHelper.java new file mode 100644 index 000000000..f04406c7e --- /dev/null +++ b/common/schema-builder/src/main/java/io/smallrye/graphql/schema/helper/TargetOperationHelper.java @@ -0,0 +1,55 @@ +package io.smallrye.graphql.schema.helper; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.jboss.jandex.AnnotationInstance; +import org.jboss.jandex.AnnotationTarget; +import org.jboss.jandex.DotName; +import org.jboss.jandex.MethodParameterInfo; +import org.jboss.jandex.Type; +import org.jboss.logging.Logger; + +import io.smallrye.graphql.schema.Annotations; +import io.smallrye.graphql.schema.ScanningContext; + +/** + * Finds all @Target input fields. + */ +public class TargetOperationHelper { + private final Logger LOG = Logger.getLogger(TargetOperationHelper.class.getName()); + + private final Map> targetAnnotations = scanAllTargetAnnotations(); + + public Map> getTargetAnnotations() { + return targetAnnotations; + } + + private Map> scanAllTargetAnnotations() { + Map> targetFields = new HashMap<>(); + Collection targetAnnotations = ScanningContext.getIndex().getAnnotations(Annotations.TARGET); + for (AnnotationInstance ai : targetAnnotations) { + AnnotationTarget target = ai.target(); + if (target.kind().equals(AnnotationTarget.Kind.METHOD_PARAMETER)) { + MethodParameterInfo methodParameter = target.asMethodParameter(); + Type targetType = methodParameter.method().parameterType(methodParameter.position()); + DotName name = getName(targetType); + targetFields.computeIfAbsent(name, k -> new ArrayList<>()).add(methodParameter); + } else { + LOG.warn("Ignoring " + ai.target() + " on kind " + ai.target().kind() + ". Only expecting @" + + Annotations.TARGET.local() + " on Method parameters"); + } + } + return targetFields; + } + + private DotName getName(Type targetType) { + if (targetType.kind().equals(Type.Kind.PARAMETERIZED_TYPE)) { + return targetType.asParameterizedType().name(); + } + return targetType.name(); + } +} diff --git a/common/schema-builder/src/test/java/io/smallrye/graphql/index/SchemaBuilderTest.java b/common/schema-builder/src/test/java/io/smallrye/graphql/index/SchemaBuilderTest.java index 46b520450..a18576766 100644 --- a/common/schema-builder/src/test/java/io/smallrye/graphql/index/SchemaBuilderTest.java +++ b/common/schema-builder/src/test/java/io/smallrye/graphql/index/SchemaBuilderTest.java @@ -179,6 +179,24 @@ public void testSchemaWithBatchSourceFieldNameDuplicates() { } } + @Test + public void testSchemaWithTargetInputFieldNameDuplicates() { + try { + Indexer indexer = new Indexer(); + indexDirectory(indexer, "io/smallrye/graphql/index/duplicates/target"); + indexDirectory(indexer, "io/smallrye/graphql/index/duplicates/target/targetfield"); + IndexView index = indexer.complete(); + SchemaBuilder.build(index); + Assertions.fail( + "Schema should not build when there are both input field and target input field with the same defined name"); + } catch (SchemaBuilderException e) { + // ok + assertEquals("Input type 'SomeInputInput' already contains field named 'password'" + + " so target input field, with the same name, cannot be applied. You can resolve this conflict using @Name on the target method.", + e.getMessage()); + } + } + @Test public void testSchemaWithInheritFieldBySubtype() { Indexer indexer = new Indexer(); diff --git a/common/schema-model/src/main/java/io/smallrye/graphql/schema/model/InputType.java b/common/schema-model/src/main/java/io/smallrye/graphql/schema/model/InputType.java index 9d9e3978a..98475fe34 100644 --- a/common/schema-model/src/main/java/io/smallrye/graphql/schema/model/InputType.java +++ b/common/schema-model/src/main/java/io/smallrye/graphql/schema/model/InputType.java @@ -20,6 +20,8 @@ public final class InputType extends Reference { private Map fields = new LinkedHashMap<>(); + private Map targetFields = new LinkedHashMap<>(); + /** * All fields which are required by the creator method (e.g. {@code @JsonbCreator}). */ @@ -61,6 +63,26 @@ public boolean hasField(String fieldName) { return this.fields.containsKey(fieldName); } + public Map getTargetFields() { + return targetFields; + } + + public void setTargetFields(Map targetFields) { + this.targetFields = targetFields; + } + + public void addTargetField(Operation targetField) { + this.targetFields.put(targetField.getName(), targetField); + } + + public boolean hasTargetFields() { + return !this.targetFields.isEmpty(); + } + + public boolean hasTargetField(String fieldName) { + return this.targetFields.containsKey(fieldName); + } + public List getCreatorParameters() { return creatorParameters; } @@ -75,8 +97,8 @@ public void addCreatorParameter(Field creatorParameter) { @Override public String toString() { - return "InputType{" + "description=" + description + ", fields=" + fields + ", creatorParameters=" + creatorParameters - + '}'; + return "InputType{" + "description=" + description + ", fields=" + fields + ", targetFields=" + + targetFields + ", creatorParameters=" + creatorParameters + '}'; } } diff --git a/common/schema-model/src/main/java/io/smallrye/graphql/schema/model/Operation.java b/common/schema-model/src/main/java/io/smallrye/graphql/schema/model/Operation.java index b2ebb79b9..3eb300221 100644 --- a/common/schema-model/src/main/java/io/smallrye/graphql/schema/model/Operation.java +++ b/common/schema-model/src/main/java/io/smallrye/graphql/schema/model/Operation.java @@ -1,5 +1,6 @@ package io.smallrye.graphql.schema.model; +import java.util.ArrayList; import java.util.LinkedList; import java.util.List; @@ -34,6 +35,15 @@ public final class Operation extends Field { */ private Reference sourceFieldOn = null; + /** + * If this is a target input field, the input object it's on + */ + private Reference targetFieldOn = null; + + private List parameterClassNames = new ArrayList<>(); + private short targetParameterPosition; + private short valueParameterPosition; + /** * If this should be executed blocking. By default all normal object returns will be blocking, except if marked * with @NonBlocking @@ -96,6 +106,42 @@ public boolean isSourceField() { return this.sourceFieldOn != null; } + public Reference getTargetFieldOn() { + return targetFieldOn; + } + + public void setTargetFieldOn(Reference targetFieldOn) { + this.targetFieldOn = targetFieldOn; + } + + public boolean isTargetField() { + return this.targetFieldOn != null; + } + + public List getParameterClassNames() { + return parameterClassNames; + } + + public void setParameterClassNames(List parameterClassNames) { + this.parameterClassNames = parameterClassNames; + } + + public short getTargetParameterPosition() { + return targetParameterPosition; + } + + public void setTargetParameterPosition(short targetParameterPosition) { + this.targetParameterPosition = targetParameterPosition; + } + + public short getValueParameterPosition() { + return valueParameterPosition; + } + + public void setValueParameterPosition(short valueParameterPosition) { + this.valueParameterPosition = valueParameterPosition; + } + public Execute getExecute() { return execute; } @@ -107,6 +153,6 @@ public void setExecute(Execute execute) { @Override public String toString() { return "Operation{" + "className=" + className + ", arguments=" + arguments + ", operationType=" + operationType - + ", sourceFieldOn=" + sourceFieldOn + '}'; + + ", sourceFieldOn=" + sourceFieldOn + ", targetFieldOn=" + targetFieldOn + '}'; } } diff --git a/docs/target-for-input-types.md b/docs/target-for-input-types.md new file mode 100644 index 000000000..23e552ab9 --- /dev/null +++ b/docs/target-for-input-types.md @@ -0,0 +1,65 @@ +# Target fields for input types + +The `@Source` annotation extends **output** types by adding externally-defined fields. Its +counterpart for **input** types is `@Target` (`io.smallrye.graphql.api.Target`). + +With `@Target`, you can add fields to a GraphQL input type the same way `@Source` adds fields to +an output type. The annotated parameter receives the input object, and a sibling parameter +receives the value sent by the client for that field. The method name becomes the input field +name in the schema (overridable with `@Name`), and `@Description` can be added on the method. + +Server-side example: + +```java +import io.smallrye.graphql.api.Target; + +@GraphQLApi +public class CharacterService { + + @Mutation + public Character save(Character input) { + // the input may have been modified by @Target methods below + return input; + } + + public void applyTwitterHandle(@Target Character character, String twitterHandle) { + character.setTwitterHandle(twitterHandle); + } +} +``` + +This generates an extended input type in the schema: + +```graphql +input CharacterInput { + # ... regular fields ... + twitterHandle: String # added by the applyTwitterHandle method +} +``` + +When a client sends a mutation that includes the `twitterHandle` field, the corresponding +`@Target` method is invoked at runtime. If the field is omitted, the method is not called. + +Multiple `@Target` methods on the same input type are supported: + +```java +public void applyTwitterHandle(@Target Character character, String twitterHandle) { + character.setTwitterHandle(twitterHandle); +} + +public void applyNumber(@Target Character character, Integer rating) { + character.setRating(rating); +} +``` + +A `Context` parameter can be injected into target methods: + +```java +public void applyWithContext(@Target Character character, String value, Context context) { + String fieldName = context.getFieldName(); + // ... +} +``` + +> [NOTE] +> `@Target` is currently marked as experimental. diff --git a/mkdocs.yml b/mkdocs.yml index 352e7c7c5..c37190049 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -15,6 +15,7 @@ nav: - Custom scalars: 'custom-scalar.md' - Inspecting executable directives: 'inspecting-executable-directives-on-server-side.md' - Namespaces: 'namespaces-on-server-side.md' + - Target input fields: 'target-for-input-types.md' - Kotlin support: 'kotlin.md' - Typesafe client: - Basic usage: 'typesafe-client-usage.md' diff --git a/server/api/src/main/java/io/smallrye/graphql/api/Target.java b/server/api/src/main/java/io/smallrye/graphql/api/Target.java new file mode 100644 index 000000000..3e982cdde --- /dev/null +++ b/server/api/src/main/java/io/smallrye/graphql/api/Target.java @@ -0,0 +1,55 @@ +package io.smallrye.graphql.api; + +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; + +import io.smallrye.common.annotation.Experimental; + +/** + * Extends a GraphQL input type by adding an externally defined input field. + *
+ *
+ * The GraphQL input type which is extended is the GraphQL input type corresponding to the Java type of the annotated + * method parameter. + *
+ *
+ * At runtime, when a mutation receives input containing the added field, the method declaring the annotated parameter + * is invoked for that field. This allows application code to handle values that are supplied through the extended input + * model. + *
+ *
+ * By default, the name of the added input field is the name of the method. + * + *
+ *
+ * For example, a user might annotate a method's parameter as such: + * + *
+ * public class CharacterService {
+ *     public Character addTwitterHandle(
+ *                          {@literal @}Target Character character,
+ *                          String twitterHandle) {
+ *         character.setTwitterHandle(twitterHandle);
+ *         return character;
+ *     }
+ * }
+ * 
+ *

+ * Schema generation of this would result in a stanza such as: + * + *

+ * input CharacterInput {
+ *    # Other fields ...
+ *    addTwitterHandle: String
+ * }
+ * 
+ */ +@java.lang.annotation.Target(ElementType.PARAMETER) +@Retention(RUNTIME) +@Documented +@Experimental("Marks a method parameter as the input object extended by an externally defined input field") +public @interface Target { +} diff --git a/server/implementation/src/main/java/io/smallrye/graphql/bootstrap/Bootstrap.java b/server/implementation/src/main/java/io/smallrye/graphql/bootstrap/Bootstrap.java index 8850326ec..0331af671 100644 --- a/server/implementation/src/main/java/io/smallrye/graphql/bootstrap/Bootstrap.java +++ b/server/implementation/src/main/java/io/smallrye/graphql/bootstrap/Bootstrap.java @@ -658,9 +658,9 @@ private GraphQLInputObjectType createGraphQLInputObjectType(InputType inputType) } // Fields - if (inputType.hasFields()) { + if (inputType.hasFields() || inputType.hasTargetFields()) { inputObjectTypeBuilder = inputObjectTypeBuilder - .fields(createGraphQLInputObjectFieldsFromFields(inputType.getFields().values())); + .fields(createGraphQLInputObjectFieldsFromInputType(inputType)); // Register this input for possible JsonB usage JsonInputRegistry.register(inputType); } @@ -959,7 +959,7 @@ private Optional getAutoMapArgument(Field field) { return Optional.empty(); } - private List createGraphQLInputObjectFieldsFromFields(Collection fields) { + private List createGraphQLInputObjectFieldsFromFields(Collection fields) { List graphQLInputObjectFields = new ArrayList<>(); for (Field field : fields) { graphQLInputObjectFields.add(createGraphQLInputObjectFieldFromField(field)); @@ -967,6 +967,18 @@ private List createGraphQLInputObjectFieldsFromFields(C return graphQLInputObjectFields; } + private List createGraphQLInputObjectFieldsFromInputType(InputType inputType) { + List graphQLInputObjectFields = new ArrayList<>(); + if (inputType.hasFields()) { + graphQLInputObjectFields.addAll(createGraphQLInputObjectFieldsFromFields(inputType.getFields().values())); + } + if (inputType.hasTargetFields()) { + graphQLInputObjectFields.addAll( + createGraphQLInputObjectFieldsFromFields(inputType.getTargetFields().values())); + } + return graphQLInputObjectFields; + } + private GraphQLInputObjectField createGraphQLInputObjectFieldFromField(Field field) { GraphQLInputObjectField.Builder inputFieldBuilder = GraphQLInputObjectField.newInputObjectField() .name(field.getName()) diff --git a/server/implementation/src/main/java/io/smallrye/graphql/execution/datafetcher/helper/ArgumentHelper.java b/server/implementation/src/main/java/io/smallrye/graphql/execution/datafetcher/helper/ArgumentHelper.java index 67bb6d0e0..922d4763b 100644 --- a/server/implementation/src/main/java/io/smallrye/graphql/execution/datafetcher/helper/ArgumentHelper.java +++ b/server/implementation/src/main/java/io/smallrye/graphql/execution/datafetcher/helper/ArgumentHelper.java @@ -29,6 +29,7 @@ import io.smallrye.graphql.schema.model.AdaptWith; import io.smallrye.graphql.schema.model.Argument; import io.smallrye.graphql.schema.model.Field; +import io.smallrye.graphql.schema.model.Operation; import io.smallrye.graphql.schema.model.Reference; import io.smallrye.graphql.schema.model.ReferenceType; import io.smallrye.graphql.transformation.AbstractDataFetcherException; @@ -377,6 +378,7 @@ private static boolean isJacksonJsonNodeType(String className) { private Object correctComplexObjectFromMap(Map m, Field field, DataFetchingEnvironment dfe) throws AbstractDataFetcherException { String className = field.getReference().getClassName(); + Map targetValues = removeTargetValues(m, className); // Let's see if there are any fields that needs transformation or adaption if (InputFieldsInfo.hasTransformationFields(className)) { @@ -457,12 +459,70 @@ private Object correctComplexObjectFromMap(Map m, Field field, DataFetchingEnvir } } - return result; + return invokeTargetFields(result, className, targetValues, dfe); } catch (JsonProcessingException e) { throw new TransformException(e, field, m); } } + private Map removeTargetValues(Map m, String className) { + Map targetValues = new HashMap<>(); + if (InputFieldsInfo.hasTargetFields(className)) { + for (String targetFieldName : InputFieldsInfo.getTargetFields(className).keySet()) { + if (m.containsKey(targetFieldName)) { + targetValues.put(targetFieldName, m.remove(targetFieldName)); + } + } + } + return targetValues; + } + + private Object invokeTargetFields(Object target, String className, Map targetValues, + DataFetchingEnvironment dfe) throws AbstractDataFetcherException { + if (targetValues.isEmpty()) { + return target; + } + Map targetFields = InputFieldsInfo.getTargetFields(className); + Object currentTarget = target; + for (Map.Entry entry : targetValues.entrySet()) { + Operation targetField = targetFields.get(entry.getKey()); + Object result = invokeTargetField(targetField, currentTarget, entry.getValue(), dfe); + if (result != null && currentTarget.getClass().isAssignableFrom(result.getClass())) { + currentTarget = result; + } + } + return currentTarget; + } + + private Object invokeTargetField(Operation targetField, Object target, Object targetValue, DataFetchingEnvironment dfe) + throws AbstractDataFetcherException { + Object[] transformedArguments = new Object[targetField.getParameterClassNames().size()]; + transformedArguments[targetField.getTargetParameterPosition()] = target; + transformedArguments[targetField.getValueParameterPosition()] = getTargetValue(targetValue, targetField, dfe); + try { + ReflectionInvoker reflectionInvoker = new ReflectionInvoker(targetField.getClassName(), + targetField.getMethodName(), + targetField.getParameterClassNames()); + return reflectionInvoker.invoke(transformedArguments); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private Object getTargetValue(Object targetValue, Operation targetField, DataFetchingEnvironment dfe) + throws AbstractDataFetcherException { + if (targetValue == null) { + if (targetField.hasWrapper() && targetField.getWrapper().isOptional()) { + return Optional.empty(); + } + return null; + } + if (targetField.hasWrapper() && targetField.getWrapper().isOptional()) { + targetValue = Optional.of(targetValue); + } + return transformOrAdapt(targetValue, targetField, dfe); + } + /** * Recursively add null fields for creator parameters which are not present in this map. * This is required by Yasson to be able to deserialize an object from the map. diff --git a/server/implementation/src/main/java/io/smallrye/graphql/json/InputFieldsInfo.java b/server/implementation/src/main/java/io/smallrye/graphql/json/InputFieldsInfo.java index 329a9eaf0..8fe7374b2 100644 --- a/server/implementation/src/main/java/io/smallrye/graphql/json/InputFieldsInfo.java +++ b/server/implementation/src/main/java/io/smallrye/graphql/json/InputFieldsInfo.java @@ -9,6 +9,7 @@ import io.smallrye.graphql.schema.model.Field; import io.smallrye.graphql.schema.model.InputType; +import io.smallrye.graphql.schema.model.Operation; /** * Here we create a mapping of all fields in a input type that needs transformation and mapping @@ -21,6 +22,7 @@ public class InputFieldsInfo { private static final Map> inputFieldAdaptingToMap = new HashMap<>(); private static final Map> inputFieldAdaptingWithMap = new HashMap<>(); private static final Map> creatorParameters = new HashMap<>(); + private static final Map> targetFields = new HashMap<>(); private InputFieldsInfo() { } @@ -70,6 +72,9 @@ protected static void register(InputType inputType) { inputFieldAdaptingWithMap.put(inputType.getClassName(), fieldsThatNeedsAdaptingWith); } } + if (inputType.hasTargetFields()) { + targetFields.put(inputType.getClassName(), inputType.getTargetFields()); + } } public static boolean hasTransformationFields(String className) { @@ -112,4 +117,15 @@ public static List getCreatorParameters(String className) { return Collections.emptyList(); } + public static boolean hasTargetFields(String className) { + return targetFields.containsKey(className); + } + + public static Map getTargetFields(String className) { + if (targetFields.containsKey(className)) { + return targetFields.get(className); + } + return Collections.emptyMap(); + } + } diff --git a/server/implementation/src/test/java/io/smallrye/graphql/execution/TargetTest.java b/server/implementation/src/test/java/io/smallrye/graphql/execution/TargetTest.java new file mode 100644 index 000000000..bd76ea001 --- /dev/null +++ b/server/implementation/src/test/java/io/smallrye/graphql/execution/TargetTest.java @@ -0,0 +1,90 @@ +package io.smallrye.graphql.execution; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import jakarta.json.JsonArray; +import jakarta.json.JsonObject; + +import org.jboss.jandex.IndexView; +import org.junit.jupiter.api.Test; + +import io.smallrye.graphql.test.targetinput.TargetApi; + +public class TargetTest extends ExecutionTestBase { + @Override + protected IndexView getIndex() { + return Indexer.getTestIndex(TargetApi.class); + } + + @Test + public void shouldExecuteTargetMethodForInputField() { + JsonObject response = executeAndGetResult(TARGET_MUTATION, null); + JsonArray errors = response.getJsonArray("errors"); + JsonObject data = response.getJsonObject("data"); + + JsonObject result = data.getJsonObject("save"); + assertEquals("SmallRye", result.getString("title")); + assertEquals("target:SmallRye:GraphQL", result.getString("value")); + assertNull(errors); + } + + @Test + public void shouldNotExecuteTargetMethodWhenInputFieldIsOmitted() { + JsonObject response = executeAndGetResult(TARGET_OMITTED_MUTATION, null); + JsonObject result = response.getJsonObject("data").getJsonObject("save"); + + assertEquals("SmallRye", result.getString("title")); + assertTrue(result.isNull("value")); + assertNull(response.getJsonArray("errors")); + } + + @Test + public void shouldTransformTargetInputFieldValue() { + JsonObject response = executeAndGetResult(TARGET_NUMBER_MUTATION, null); + JsonObject result = response.getJsonObject("data").getJsonObject("save"); + + assertEquals("SmallRye", result.getString("title")); + assertEquals("number:42", result.getString("value")); + assertNull(response.getJsonArray("errors")); + } + + @Test + public void shouldInjectContextIntoTargetMethod() { + JsonObject response = executeAndGetResult(TARGET_CONTEXT_MUTATION, null); + JsonObject result = response.getJsonObject("data").getJsonObject("save"); + + assertEquals("SmallRye", result.getString("title")); + assertEquals("context:save:GraphQL", result.getString("value")); + assertNull(response.getJsonArray("errors")); + } + + private static final String TARGET_MUTATION = "mutation {\n" + + " save(input: { title: \"SmallRye\", applyValue: \"GraphQL\" }) {\n" + + " title\n" + + " value\n" + + " }\n" + + "}"; + + private static final String TARGET_OMITTED_MUTATION = "mutation {\n" + + " save(input: { title: \"SmallRye\" }) {\n" + + " title\n" + + " value\n" + + " }\n" + + "}"; + + private static final String TARGET_NUMBER_MUTATION = "mutation {\n" + + " save(input: { title: \"SmallRye\", applyNumber: 42 }) {\n" + + " title\n" + + " value\n" + + " }\n" + + "}"; + + private static final String TARGET_CONTEXT_MUTATION = "mutation {\n" + + " save(input: { title: \"SmallRye\", applyWithContext: \"GraphQL\" }) {\n" + + " title\n" + + " value\n" + + " }\n" + + "}"; +} diff --git a/server/implementation/src/test/java/io/smallrye/graphql/schema/SchemaTest.java b/server/implementation/src/test/java/io/smallrye/graphql/schema/SchemaTest.java index 9c48c5f94..5ba32ed26 100644 --- a/server/implementation/src/test/java/io/smallrye/graphql/schema/SchemaTest.java +++ b/server/implementation/src/test/java/io/smallrye/graphql/schema/SchemaTest.java @@ -39,6 +39,7 @@ import graphql.schema.GraphQLFieldDefinition; import graphql.schema.GraphQLInputObjectField; import graphql.schema.GraphQLInputObjectType; +import graphql.schema.GraphQLNamedType; import graphql.schema.GraphQLObjectType; import graphql.schema.GraphQLSchema; import graphql.schema.GraphQLUnionType; @@ -162,6 +163,21 @@ void schemaWithSourceFieldDirectives() { assertEquals(1, batchedSourceField.getDirectives().size()); } + @Test + void schemaWithTargetInputField() { + GraphQLSchema graphQLSchema = createGraphQLSchema(TargetInputFieldTestApi.class, + TargetInputFieldTestApi.TargetType.class); + GraphQLInputObjectType inputType = graphQLSchema.getTypeAs("TargetTypeInput"); + + GraphQLInputObjectField targetField = inputType.getField("renamed"); + assertNotNull(targetField); + assertEquals("Int", ((GraphQLNamedType) targetField.getType()).getName()); + assertEquals("Target field description", targetField.getDescription()); + + GraphQLObjectType outputType = graphQLSchema.getTypeAs("TargetType"); + assertNull(outputType.getFieldDefinition("renamed")); + } + @Test void schemaWithUnionDirectives() { GraphQLSchema graphQLSchema = createGraphQLSchema(UnionDirective.class, UnionTestApi.class, diff --git a/server/implementation/src/test/java/io/smallrye/graphql/schema/TargetInputFieldTestApi.java b/server/implementation/src/test/java/io/smallrye/graphql/schema/TargetInputFieldTestApi.java new file mode 100644 index 000000000..f42e4deb9 --- /dev/null +++ b/server/implementation/src/test/java/io/smallrye/graphql/schema/TargetInputFieldTestApi.java @@ -0,0 +1,32 @@ +package io.smallrye.graphql.schema; + +import org.eclipse.microprofile.graphql.Description; +import org.eclipse.microprofile.graphql.GraphQLApi; +import org.eclipse.microprofile.graphql.Mutation; +import org.eclipse.microprofile.graphql.Name; +import org.eclipse.microprofile.graphql.Query; + +import io.smallrye.graphql.api.Target; + +@GraphQLApi +public class TargetInputFieldTestApi { + + @Query + public String ping() { + return "pong"; + } + + @Mutation + public TargetType save(TargetType input) { + return input; + } + + @Name("renamed") + @Description("Target field description") + public void apply(@Target TargetType target, Integer value) { + } + + public static class TargetType { + public String title; + } +} diff --git a/server/implementation/src/test/java/io/smallrye/graphql/test/targetinput/TargetApi.java b/server/implementation/src/test/java/io/smallrye/graphql/test/targetinput/TargetApi.java new file mode 100644 index 000000000..d1775c79a --- /dev/null +++ b/server/implementation/src/test/java/io/smallrye/graphql/test/targetinput/TargetApi.java @@ -0,0 +1,34 @@ +package io.smallrye.graphql.test.targetinput; + +import org.eclipse.microprofile.graphql.GraphQLApi; +import org.eclipse.microprofile.graphql.Mutation; +import org.eclipse.microprofile.graphql.Name; +import org.eclipse.microprofile.graphql.Query; + +import io.smallrye.graphql.api.Context; +import io.smallrye.graphql.api.Target; + +@GraphQLApi +public class TargetApi { + @Query + public String ping() { + return "pong"; + } + + @Mutation + public TargetInput save(@Name("input") TargetInput input) { + return input; + } + + public void applyValue(@Target TargetInput input, String value) { + input.value = "target:" + input.title + ":" + value; + } + + public void applyNumber(@Target TargetInput input, Integer number) { + input.value = "number:" + number; + } + + public void applyWithContext(@Target TargetInput input, String value, Context context) { + input.value = "context:" + context.getFieldName() + ":" + value; + } +} diff --git a/server/implementation/src/test/java/io/smallrye/graphql/test/targetinput/TargetInput.java b/server/implementation/src/test/java/io/smallrye/graphql/test/targetinput/TargetInput.java new file mode 100644 index 000000000..489903080 --- /dev/null +++ b/server/implementation/src/test/java/io/smallrye/graphql/test/targetinput/TargetInput.java @@ -0,0 +1,6 @@ +package io.smallrye.graphql.test.targetinput; + +public class TargetInput { + public String title; + public String value; +}