From 3aeb34d3d63064b836aae38fc930dbdd76209af1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BCdiger=20zu=20Dohna?= Date: Thu, 27 May 2021 04:55:05 +0200 Subject: [PATCH 1/4] #521: initial GraphQL Federation extension --- server/federation/README.adoc | 43 ++ server/federation/api/pom.xml | 28 ++ .../graphql/federation/api/Extends.java | 19 + .../graphql/federation/api/External.java | 18 + .../federation/api/FederatedSource.java | 19 + .../smallrye/graphql/federation/api/Key.java | 22 + .../graphql/federation/api/Provides.java | 20 + .../graphql/federation/api/Requires.java | 21 + server/federation/pom.xml | 39 ++ server/federation/runtime/pom.xml | 44 ++ .../graphql/federation/impl/Federation.java | 455 ++++++++++++++++++ server/pom.xml | 3 +- 12 files changed, 730 insertions(+), 1 deletion(-) create mode 100644 server/federation/README.adoc create mode 100644 server/federation/api/pom.xml create mode 100644 server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Extends.java create mode 100644 server/federation/api/src/main/java/io/smallrye/graphql/federation/api/External.java create mode 100644 server/federation/api/src/main/java/io/smallrye/graphql/federation/api/FederatedSource.java create mode 100644 server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Key.java create mode 100644 server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Provides.java create mode 100644 server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Requires.java create mode 100644 server/federation/pom.xml create mode 100644 server/federation/runtime/pom.xml create mode 100644 server/federation/runtime/src/main/java/io/smallrye/graphql/federation/impl/Federation.java diff --git a/server/federation/README.adoc b/server/federation/README.adoc new file mode 100644 index 000000000..0e5d5b996 --- /dev/null +++ b/server/federation/README.adoc @@ -0,0 +1,43 @@ += GraphQL Federation + +Extension library to add support for https://www.apollographql.com/docs/federation/federation-spec/[GraphQL Federation]. + +The `api` module defines the directives `@Key`, etc. The `runtime` module hooks into the SmallRye GraphQL events to dynamically enhance the GraphQL schema with the Federation extras (`_Entity`, `_Service`, etc.). + +== API + +Annotate the type you want to extend with `@Key`, e.g.: + +[source,java] +---------- +@Key(fields = "id") +public class Film { + String id; + String year; + String name; + // ... +} +---------- + +In the service that extends that type, declare a reduced view that only defines the keys, e.g. `id`. Annotate the type as `@Extends` and the field as `@External`, e.g.: + +[source,java] +---------- +@Extends @Key(fields = "id") +public class Film { + @External String id; +} +---------- + +And write a resolver method that adds the fields to that type, just like a 'local' `@Source` resolver would, but use `@FederatedSource` instead, e.g.: + +[source,java] +---------- +class Reviews { + public List reviews(@FederatedSource Film film) { + //... + } +} +---------- + +For a full example, see https://github.com/t1/graphql-federation-demo diff --git a/server/federation/api/pom.xml b/server/federation/api/pom.xml new file mode 100644 index 000000000..38a9c0fa4 --- /dev/null +++ b/server/federation/api/pom.xml @@ -0,0 +1,28 @@ + + + 4.0.0 + + + io.smallrye + smallrye-graphql-federation-parent + 1.2.3-SNAPSHOT + ../pom.xml + + + smallrye-graphql-federation-api + + + + org.eclipse.microprofile.graphql + microprofile-graphql-api + provided + + + io.smallrye + smallrye-graphql-api + provided + + + diff --git a/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Extends.java b/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Extends.java new file mode 100644 index 000000000..ddf6be860 --- /dev/null +++ b/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Extends.java @@ -0,0 +1,19 @@ +package io.smallrye.graphql.federation.api; + +import static io.smallrye.graphql.api.DirectiveLocation.INTERFACE; +import static io.smallrye.graphql.api.DirectiveLocation.OBJECT; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; + +import org.eclipse.microprofile.graphql.Description; + +import io.smallrye.graphql.api.Directive; + +/** directive @extends on OBJECT | INTERFACE */ +@Directive(on = { OBJECT, INTERFACE }) +@Description("Some libraries such as graphql-java don't have native support for type extensions in their printer. " + + "Apollo Federation supports using an @extends directive in place of extend type to annotate type references.") +@Retention(RUNTIME) +public @interface Extends { +} diff --git a/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/External.java b/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/External.java new file mode 100644 index 000000000..76acdf44d --- /dev/null +++ b/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/External.java @@ -0,0 +1,18 @@ +package io.smallrye.graphql.federation.api; + +import static io.smallrye.graphql.api.DirectiveLocation.FIELD_DEFINITION; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; + +import org.eclipse.microprofile.graphql.Description; + +import io.smallrye.graphql.api.Directive; + +/** directive @external on FIELD_DEFINITION */ +@Directive(on = FIELD_DEFINITION) +@Description("The @external directive is used to mark a field as owned by another service. " + + "This allows service A to use fields from service B while also knowing at runtime the types of that field.") +@Retention(RUNTIME) +public @interface External { +} diff --git a/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/FederatedSource.java b/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/FederatedSource.java new file mode 100644 index 000000000..6de301503 --- /dev/null +++ b/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/FederatedSource.java @@ -0,0 +1,19 @@ +package io.smallrye.graphql.federation.api; + +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +/** + * A federated resolver method is the federated equivalent to a 'local' resolver method (with a parameter annotated as + * {@link org.eclipse.microprofile.graphql.Source @Source}), i.e. it adds a field with the name of the method + * and the type of the method return type to the source object. + * The class of the source parameter must be annotated as {@link Extends @Extends} + * and have at least one field annotated as {@link External @External}. + */ +@Retention(RUNTIME) +@Target(PARAMETER) +public @interface FederatedSource { +} diff --git a/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Key.java b/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Key.java new file mode 100644 index 000000000..e157fa83b --- /dev/null +++ b/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Key.java @@ -0,0 +1,22 @@ +package io.smallrye.graphql.federation.api; + +import static io.smallrye.graphql.api.DirectiveLocation.INTERFACE; +import static io.smallrye.graphql.api.DirectiveLocation.OBJECT; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; + +import org.eclipse.microprofile.graphql.Description; +import org.eclipse.microprofile.graphql.NonNull; + +import io.smallrye.graphql.api.Directive; + +/** directive @key(fields: _FieldSet!) on OBJECT | INTERFACE */ +@Directive(on = { OBJECT, INTERFACE }) +@Description("The @key directive is used to indicate a combination of fields that can be used to uniquely identify " + + "and fetch an object or interface.") +@Retention(RUNTIME) +public @interface Key { + @NonNull + String[] fields(); +} diff --git a/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Provides.java b/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Provides.java new file mode 100644 index 000000000..3c6a8a2db --- /dev/null +++ b/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Provides.java @@ -0,0 +1,20 @@ +package io.smallrye.graphql.federation.api; + +import static io.smallrye.graphql.api.DirectiveLocation.FIELD_DEFINITION; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; + +import org.eclipse.microprofile.graphql.Description; +import org.eclipse.microprofile.graphql.NonNull; + +import io.smallrye.graphql.api.Directive; + +/** directive @provides(fields: _FieldSet!) on FIELD_DEFINITION */ +@Directive(on = FIELD_DEFINITION) +@Description("When resolving the annotated field, this service can provide additional, normally `@external` fields.") +@Retention(RUNTIME) +public @interface Provides { + @NonNull + String[] fields(); +} diff --git a/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Requires.java b/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Requires.java new file mode 100644 index 000000000..144c22e8e --- /dev/null +++ b/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Requires.java @@ -0,0 +1,21 @@ +package io.smallrye.graphql.federation.api; + +import static io.smallrye.graphql.api.DirectiveLocation.FIELD_DEFINITION; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; + +import org.eclipse.microprofile.graphql.Description; +import org.eclipse.microprofile.graphql.NonNull; + +import io.smallrye.graphql.api.Directive; + +/** directive @requires(fields: _FieldSet!) on FIELD_DEFINITION */ +@Directive(on = FIELD_DEFINITION) +@Description("In order to resolve the annotated field, this service needs these additional `@external` fields, " + + "even when the client didn't request them.") +@Retention(RUNTIME) +public @interface Requires { + @NonNull + String[] fields(); +} diff --git a/server/federation/pom.xml b/server/federation/pom.xml new file mode 100644 index 000000000..34dd0685a --- /dev/null +++ b/server/federation/pom.xml @@ -0,0 +1,39 @@ + + + 4.0.0 + + + io.smallrye + smallrye-graphql-server-parent + 1.2.3-SNAPSHOT + ../pom.xml + + + smallrye-graphql-federation-parent + pom + + + api + runtime + + + + + + org.apache.maven.plugins + maven-source-plugin + 3.2.1 + + + attach-sources + + jar-no-fork + + + + + + + diff --git a/server/federation/runtime/pom.xml b/server/federation/runtime/pom.xml new file mode 100644 index 000000000..55e5871df --- /dev/null +++ b/server/federation/runtime/pom.xml @@ -0,0 +1,44 @@ + + + 4.0.0 + + + io.smallrye + smallrye-graphql-federation-parent + 1.2.3-SNAPSHOT + ../pom.xml + + + smallrye-graphql-federation-runtime + + + + io.smallrye + smallrye-graphql-federation-api + ${project.version} + + + + jakarta.enterprise + jakarta.enterprise.cdi-api + provided + + + io.smallrye + smallrye-graphql + provided + + + io.smallrye + smallrye-graphql-schema-builder + provided + + + org.jboss.logging + jboss-logging + provided + + + diff --git a/server/federation/runtime/src/main/java/io/smallrye/graphql/federation/impl/Federation.java b/server/federation/runtime/src/main/java/io/smallrye/graphql/federation/impl/Federation.java new file mode 100644 index 000000000..9d13f4d85 --- /dev/null +++ b/server/federation/runtime/src/main/java/io/smallrye/graphql/federation/impl/Federation.java @@ -0,0 +1,455 @@ +package io.smallrye.graphql.federation.impl; + +import static graphql.Scalars.GraphQLString; +import static graphql.schema.GraphQLArgument.newArgument; +import static graphql.schema.GraphQLFieldDefinition.newFieldDefinition; +import static graphql.schema.GraphQLList.list; +import static graphql.schema.GraphQLNonNull.nonNull; +import static java.util.stream.Collectors.toList; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Scanner; +import java.util.function.Function; + +import javax.enterprise.context.ApplicationScoped; +import javax.enterprise.event.Observes; +import javax.enterprise.inject.spi.CDI; +import javax.inject.Inject; + +import org.eclipse.microprofile.graphql.GraphQLApi; +import org.jboss.jandex.AnnotationInstance; +import org.jboss.jandex.AnnotationTarget; +import org.jboss.jandex.ClassInfo; +import org.jboss.jandex.DotName; +import org.jboss.jandex.MethodInfo; +import org.jboss.jandex.MethodParameterInfo; +import org.jboss.jandex.Type; +import org.jboss.logging.Logger; + +import graphql.TypeResolutionEnvironment; +import graphql.scalar.GraphqlStringCoercing; +import graphql.schema.Coercing; +import graphql.schema.CoercingParseLiteralException; +import graphql.schema.CoercingParseValueException; +import graphql.schema.CoercingSerializeException; +import graphql.schema.DataFetchingEnvironment; +import graphql.schema.FieldCoordinates; +import graphql.schema.GraphQLCodeRegistry; +import graphql.schema.GraphQLFieldDefinition; +import graphql.schema.GraphQLObjectType; +import graphql.schema.GraphQLScalarType; +import graphql.schema.GraphQLSchema; +import graphql.schema.GraphQLSchema.Builder; +import graphql.schema.GraphQLUnionType; +import io.smallrye.graphql.bootstrap.Config; +import io.smallrye.graphql.execution.SchemaPrinter; +import io.smallrye.graphql.execution.event.EventEmitter; +import io.smallrye.graphql.federation.api.External; +import io.smallrye.graphql.federation.api.FederatedSource; +import io.smallrye.graphql.federation.api.Key; +import io.smallrye.graphql.schema.ScanningContext; +import io.smallrye.graphql.schema.model.Argument; +import io.smallrye.graphql.schema.model.Operation; +import io.smallrye.graphql.schema.model.Schema; + +/** + * @see GraphQL Federation Spec + */ +@GraphQLApi +@ApplicationScoped +public class Federation { + private static final Logger LOG = Logger.getLogger(EventEmitter.class); + private static final DotName KEY = DotName.createSimple(Key.class.getName()); + private static final DotName FEDERATED_SOURCE = DotName.createSimple(FederatedSource.class.getName()); + + private static final Config PRINTER_CONFIG = new Config() { + @Override + public boolean isIncludeDirectivesInSchema() { + return true; + } + + @Override + public boolean isIncludeScalarsInSchema() { + return true; + } + }; + + private static final GraphQLScalarType _FieldSet = GraphQLScalarType.newScalar().name("_FieldSet") + .coercing(new GraphqlStringCoercing()).build(); + + private static final GraphQLScalarType _Any = GraphQLScalarType.newScalar().name("_Any") + .coercing(new AnyCoercing()).build(); + + public static class AnyCoercing implements Coercing { + @Override + public Object serialize(Object dataFetcherResult) throws CoercingSerializeException { + return dataFetcherResult; + } + + @Override + public Object parseValue(Object input) throws CoercingParseValueException { + return input; + } + + @Override + public Object parseLiteral(Object input) throws CoercingParseLiteralException { + return input; + } + } + + @Inject + Schema schema; + + private GraphQLUnionType _Entity; + private GraphQLFieldDefinition _entities; + + private GraphQLFieldDefinition _service; + private GraphQLObjectType _Service; + private GraphQLFieldDefinition _Service_sdl; + private String rawServiceSchema; + + private GraphQLObjectType.Builder query; + private GraphQLCodeRegistry.Builder codeRegistry; + + private final Map, FederatedEntityResolver> federatedEntityResolvers = new LinkedHashMap<>(); + private final Map, MainEntityResolver> mainEntityResolvers = new LinkedHashMap<>(); + + public GraphQLSchema.Builder beforeSchemaBuild(@Observes GraphQLSchema.Builder builder) { + GraphQLSchema original = builder.build(); + this.rawServiceSchema = rawServiceSchema(original); + + // TODO C: make the query builder available from SmallRye + this.query = GraphQLObjectType.newObject(original.getQueryType()); + // TODO C: make the GraphQLCodeRegistry available from SmallRye + this.codeRegistry = GraphQLCodeRegistry.newCodeRegistry(original.getCodeRegistry()); + + addScalars(builder); + addEntityResolvers(); + addUnions(builder); + addQueries(); + addCode(); + + builder.query(query); + builder.codeRegistry(codeRegistry.build()); + return builder; + } + + /** The SDL without the federation extras (but with the directives) */ + private String rawServiceSchema(GraphQLSchema original) { + try (InputStream stream = getClass().getResourceAsStream("/_service.graphql")) { + if (stream != null) + return new Scanner(stream).useDelimiter("\\Z").next(); + } catch (IOException e) { + throw new RuntimeException("could not load _service.graphql", e); + } + + Builder builder = GraphQLSchema.newSchema(original); + builder.clearDirectives(); + builder.clearSchemaDirectives(); + builder.clearAdditionalTypes(); + return new SchemaPrinter(PRINTER_CONFIG).print(builder.build()) + // TODO C: remove standard directive declarations + .replace("\"Marks the field or enum value as deprecated\"\n" + + "directive @deprecated(\n" + + " \"The reason for the deprecation\"\n" + + " reason: String = \"No longer supported\"\n" + + " ) on FIELD_DEFINITION | ENUM_VALUE\n" + + "\n" + + "\"Exposes a URL that specifies the behaviour of this scalar.\"\n" + + "directive @specifiedBy(\n" + + " \"The URL that specifies the behaviour of this scalar.\"\n" + + " url: String!\n" + + " ) on SCALAR\n", "") + // Apollo doesn't like a single `key` field to be wrapped in square brackets + .replaceAll("@key\\(fields ?: ?\\[([^,\\]]*)]\\)", "@key(fields: $1)"); + } + + private void addScalars(GraphQLSchema.Builder builder) { + builder.additionalType(_Any); + builder.additionalType(_FieldSet); + } + + private void addUnions(GraphQLSchema.Builder builder) { + GraphQLSchema graphQLSchema = builder.build(); + GraphQLObjectType[] entityUnionTypes = ScanningContext.getIndex() + .getAnnotations(KEY).stream() + .map(AnnotationInstance::target) + .map(AnnotationTarget::asClass) + .map(typeInfo -> toObjectType(typeInfo, graphQLSchema)) + .toArray(GraphQLObjectType[]::new); + if (entityUnionTypes.length == 0) + return; + // union _Entity = ... + _Entity = GraphQLUnionType.newUnionType().name("_Entity") + .possibleTypes(entityUnionTypes) + .description("This is a union of all types that use the @key directive, " + + "including both types native to the schema and extended types.") + .build(); + builder.additionalType(_Entity); + } + + private GraphQLObjectType toObjectType(ClassInfo typeInfo, GraphQLSchema graphQLSchema) { + String typeName = typeInfo.name().local(); + GraphQLObjectType objectType = graphQLSchema.getObjectType(typeName); + if (objectType == null) + throw new IllegalStateException("no class registered in schema for " + typeName); + return objectType; + } + + private void addQueries() { + // _entities(representations: [_Any!]!): [_Entity]! + this._entities = newFieldDefinition().name("_entities") + .argument(newArgument().name("representations").type(nonNull(list(nonNull(_Any))))) + .type(nonNull(list(_Entity))).build(); + query.field(_entities); + + // _service: _Service! + this._Service_sdl = newFieldDefinition().name("sdl").type(nonNull(GraphQLString)) + .description("The sdl representing the federated service capabilities. Includes federation directives, " + + "removes federation types, and includes rest of full schema after schema directives have been applied") + .build(); + this._Service = GraphQLObjectType.newObject() + .name("_Service") + .field(_Service_sdl) + .build(); + this._service = newFieldDefinition().name("_service").type(nonNull(_Service)).build(); + query.field(_service); + } + + private void addCode() { + codeRegistry.typeResolver(_Entity, this::resolveEntity); + codeRegistry.dataFetcher(FieldCoordinates.coordinates(query.build(), _entities), this::fetchEntities); + codeRegistry.dataFetcher(FieldCoordinates.coordinates(query.build(), _service), this::fetchService); + codeRegistry.dataFetcher(FieldCoordinates.coordinates(_Service, _Service_sdl), this::fetchServiceSDL); + } + + public GraphQLObjectType resolveEntity(TypeResolutionEnvironment environment) { + String typeName = environment.getObject().getClass().getSimpleName(); // TODO B: type renames + return environment.getSchema().getObjectType(typeName); + } + + public List fetchEntities(DataFetchingEnvironment environment) { + List> representations = environment.getArgument("representations"); + return representations.stream() + .map(this::resolve) + .collect(toList()); + } + + private Object resolve(Map representation) { + Class type = type(representation); + FederatedEntityResolver federatedEntityResolver = federatedEntityResolvers.get(type); + if (federatedEntityResolver != null) + return federatedEntityResolver.apply(representation); + MainEntityResolver mainEntityResolver = mainEntityResolvers.get(type); + if (mainEntityResolver != null) + return mainEntityResolver.apply(representation); + throw new IllegalStateException("No federated or main entity resolver found for " + type + ". " + + "Add a @Query with the @Key fields as parameters."); + } + + private Class type(Map representation) { + String typeName = (String) representation.get("__typename"); + try { + io.smallrye.graphql.schema.model.Type type = schema.getTypes().get(typeName); + if (type == null) + throw new IllegalStateException("no class registered in schema for " + typeName); + return Class.forName(type.getClassName()); + } catch (ReflectiveOperationException e) { + throw new RuntimeException("can't create extended type instance " + typeName, e); + } + } + + public Object fetchService(DataFetchingEnvironment env) { + return _service; + } + + public String fetchServiceSDL(DataFetchingEnvironment env) { + return rawServiceSchema; + } + + private void addEntityResolvers() { + addMainEntityResolvers(); + addFederatedEntityResolvers(); + } + + private void addMainEntityResolvers() { + this.schema.getQueries().stream() + .filter(this::isMainEntityResolver) + .map(MainEntityResolver::new) + .distinct() + .forEach(mainEntityResolver -> { + LOG.debug("add main entity resolver method " + mainEntityResolver.method); + mainEntityResolvers.put(mainEntityResolver.getType(), mainEntityResolver); + }); + } + + private boolean isMainEntityResolver(Operation operation) { + // TODO C: check type NOT @extends + // TODO B: check (non-optional) method params match @id + return operation.hasArguments(); + } + + private void addFederatedEntityResolvers() { + ScanningContext.getIndex() + .getAnnotations(FEDERATED_SOURCE).stream() + .map(AnnotationInstance::target) + .map(AnnotationTarget::asMethodParameter) + .map(MethodParameterInfo::method) + .map(Federation::toReflectionMethod) + .distinct() + .forEach(method -> { + LOG.debug("add federated entity resolver method " + method); + // TODO C: check that the target type IS @extends + federatedEntityResolvers.put(method.getParameterTypes()[0], new FederatedEntityResolver(schema, method)); + }); + } + + private static Method toReflectionMethod(MethodInfo methodInfo) { + try { + Class declaringClass = Class.forName(methodInfo.declaringClass().name().toString()); + Class[] parameterTypes = methodInfo.parameters().stream() + .map(Type::asClassType) + .map(Type::name) + .map(DotName::toString) + .map(Federation::toClass) + .toArray(Class[]::new); + return declaringClass.getDeclaredMethod(methodInfo.name(), parameterTypes); + } catch (ReflectiveOperationException e) { + throw new RuntimeException("can't find reflection method for " + methodInfo, e); + } + } + + private static Class toClass(String className) { + try { + return Class.forName(className); + } catch (ClassNotFoundException e) { + throw new RuntimeException("class not found: " + className, e); + } + } + + /** A federated query for a type that non-extends type by using its key fields */ + public static class MainEntityResolver implements Function, Object> { + private final Operation operation; + private final Method method; + + private MainEntityResolver(Operation operation) { + this.operation = operation; + this.method = toMethod(operation); + } + + private static Method toMethod(Operation operation) { + return toReflectionMethod(ScanningContext.getIndex() + .getClassByName(DotName.createSimple(operation.getClassName())) + .firstMethod(operation.getMethodName())); + } + + @Override + public Object apply(Map representation) { + Object declaringBean = CDI.current().select(method.getDeclaringClass()).get(); + Object[] args = new Object[method.getParameterCount()]; + for (int i = 0; i < method.getParameterCount(); i++) { + Argument argument = operation.getArguments().get(i); + args[i] = representation.get(argument.getName()); + } + try { + return method.invoke(declaringBean, args); + } catch (ReflectiveOperationException e) { + throw new RuntimeException("invocation of federated entity resolver method failed: " + method, e); + } + } + + public Class getType() { + return method.getReturnType(); + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + MainEntityResolver that = (MainEntityResolver) o; + return method.equals(that.method); + } + + @Override + public int hashCode() { + return Objects.hash(method); + } + } + + /** A method annotated as {@link FederatedSource} */ + public static class FederatedEntityResolver implements Function, Object> { + private final Schema schema; + private final Method method; + + private FederatedEntityResolver(Schema schema, Method method) { + this.schema = schema; + this.method = method; + } + + @Override + public Object apply(Map representation) { + // TODO C: batch entity resolvers + Object source = instantiate(representation); + Object value = invoke(source); + set(source, value); + return source; + } + + /** Create a prefilled instance of the type going into the federated entity resolver */ + private Object instantiate(Map representation) { + String typeName = (String) representation.get("__typename"); + try { + io.smallrye.graphql.schema.model.Type type = schema.getTypes().get(typeName); + if (type == null) + throw new IllegalStateException("no class registered in schema for " + typeName); + Class cls = Class.forName(type.getClassName()); + Object instance = cls.getConstructor().newInstance(); + // TODO B: field renames + for (String fieldName : type.getFields().keySet()) { + if ("__typename".equals(fieldName)) + continue; + String value = (String) representation.get(fieldName); + Field field = cls.getDeclaredField(fieldName); + if (field.isAnnotationPresent(External.class)) + LOG.debug("non-external field " + fieldName + " on " + typeName); + + field.setAccessible(true); + field.set(instance, value); + } + return instance; + } catch (ReflectiveOperationException e) { + throw new RuntimeException("can't create extended type instance " + typeName, e); + } + } + + private Object invoke(Object source) { + Object declaringBean = CDI.current().select(method.getDeclaringClass()).get(); + try { + return method.invoke(declaringBean, source); + } catch (ReflectiveOperationException e) { + throw new RuntimeException("invocation of federated entity resolver method failed: " + method, e); + } + } + + private void set(Object source, Object value) { + String fieldName = method.getName(); // TODO B: method renames + try { + Field field = source.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(source, value); + } catch (ReflectiveOperationException e) { + throw new RuntimeException("setting of federated entity resolver field failed: " + + source.getClass().getName() + "#" + fieldName, e); + } + } + } +} diff --git a/server/pom.xml b/server/pom.xml index 1d4c8ae08..e63b601b9 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -27,6 +27,7 @@ tck runner integration-tests + federation - \ No newline at end of file + From 87eb9f4309ed823d9e124b79318d0d30e6a979c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BCdiger=20zu=20Dohna?= Date: Fri, 28 May 2021 06:08:00 +0200 Subject: [PATCH 2/4] #521: add note that federation support is currently experimental --- server/federation/README.adoc | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/federation/README.adoc b/server/federation/README.adoc index 0e5d5b996..5efb61ba1 100644 --- a/server/federation/README.adoc +++ b/server/federation/README.adoc @@ -1,5 +1,8 @@ = GraphQL Federation +[NOTE] +The current status is experimental! + Extension library to add support for https://www.apollographql.com/docs/federation/federation-spec/[GraphQL Federation]. The `api` module defines the directives `@Key`, etc. The `runtime` module hooks into the SmallRye GraphQL events to dynamically enhance the GraphQL schema with the Federation extras (`_Entity`, `_Service`, etc.). From 8398458ac14ca289321e499b013b2eb3c07e72ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BCdiger=20zu=20Dohna?= Date: Fri, 28 May 2021 09:48:52 +0200 Subject: [PATCH 3/4] #521: mark api as `@Experimental` --- .../main/java/io/smallrye/graphql/federation/api/Extends.java | 3 +++ .../main/java/io/smallrye/graphql/federation/api/External.java | 2 ++ .../io/smallrye/graphql/federation/api/FederatedSource.java | 3 +++ .../src/main/java/io/smallrye/graphql/federation/api/Key.java | 2 ++ .../main/java/io/smallrye/graphql/federation/api/Provides.java | 3 +++ .../main/java/io/smallrye/graphql/federation/api/Requires.java | 3 +++ 6 files changed, 16 insertions(+) diff --git a/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Extends.java b/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Extends.java index ddf6be860..ce6047790 100644 --- a/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Extends.java +++ b/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Extends.java @@ -6,6 +6,7 @@ import java.lang.annotation.Retention; +import io.smallrye.common.annotation.Experimental; import org.eclipse.microprofile.graphql.Description; import io.smallrye.graphql.api.Directive; @@ -15,5 +16,7 @@ @Description("Some libraries such as graphql-java don't have native support for type extensions in their printer. " + "Apollo Federation supports using an @extends directive in place of extend type to annotate type references.") @Retention(RUNTIME) +@Experimental("SmallRye GraphQL Federation is still subject to change. " + + "Additionally, this annotation is currently only a directive without explicit support from the extension.") public @interface Extends { } diff --git a/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/External.java b/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/External.java index 76acdf44d..6b32df848 100644 --- a/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/External.java +++ b/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/External.java @@ -5,6 +5,7 @@ import java.lang.annotation.Retention; +import io.smallrye.common.annotation.Experimental; import org.eclipse.microprofile.graphql.Description; import io.smallrye.graphql.api.Directive; @@ -14,5 +15,6 @@ @Description("The @external directive is used to mark a field as owned by another service. " + "This allows service A to use fields from service B while also knowing at runtime the types of that field.") @Retention(RUNTIME) +@Experimental("SmallRye GraphQL Federation is still subject to change.") public @interface External { } diff --git a/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/FederatedSource.java b/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/FederatedSource.java index 6de301503..34669535a 100644 --- a/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/FederatedSource.java +++ b/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/FederatedSource.java @@ -1,5 +1,7 @@ package io.smallrye.graphql.federation.api; +import io.smallrye.common.annotation.Experimental; + import static java.lang.annotation.ElementType.PARAMETER; import static java.lang.annotation.RetentionPolicy.RUNTIME; @@ -15,5 +17,6 @@ */ @Retention(RUNTIME) @Target(PARAMETER) +@Experimental("SmallRye GraphQL Federation is still subject to change.") public @interface FederatedSource { } diff --git a/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Key.java b/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Key.java index e157fa83b..e2c314c62 100644 --- a/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Key.java +++ b/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Key.java @@ -6,6 +6,7 @@ import java.lang.annotation.Retention; +import io.smallrye.common.annotation.Experimental; import org.eclipse.microprofile.graphql.Description; import org.eclipse.microprofile.graphql.NonNull; @@ -16,6 +17,7 @@ @Description("The @key directive is used to indicate a combination of fields that can be used to uniquely identify " + "and fetch an object or interface.") @Retention(RUNTIME) +@Experimental("SmallRye GraphQL Federation is still subject to change.") public @interface Key { @NonNull String[] fields(); diff --git a/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Provides.java b/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Provides.java index 3c6a8a2db..4a37814ba 100644 --- a/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Provides.java +++ b/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Provides.java @@ -5,6 +5,7 @@ import java.lang.annotation.Retention; +import io.smallrye.common.annotation.Experimental; import org.eclipse.microprofile.graphql.Description; import org.eclipse.microprofile.graphql.NonNull; @@ -14,6 +15,8 @@ @Directive(on = FIELD_DEFINITION) @Description("When resolving the annotated field, this service can provide additional, normally `@external` fields.") @Retention(RUNTIME) +@Experimental("SmallRye GraphQL Federation is still subject to change. " + + "Additionally, this annotation is currently only a directive without explicit support from the extension.") public @interface Provides { @NonNull String[] fields(); diff --git a/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Requires.java b/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Requires.java index 144c22e8e..030dae908 100644 --- a/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Requires.java +++ b/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Requires.java @@ -5,6 +5,7 @@ import java.lang.annotation.Retention; +import io.smallrye.common.annotation.Experimental; import org.eclipse.microprofile.graphql.Description; import org.eclipse.microprofile.graphql.NonNull; @@ -15,6 +16,8 @@ @Description("In order to resolve the annotated field, this service needs these additional `@external` fields, " + "even when the client didn't request them.") @Retention(RUNTIME) +@Experimental("SmallRye GraphQL Federation is still subject to change. " + + "Additionally, this annotation is currently only a directive without explicit support from the extension.") public @interface Requires { @NonNull String[] fields(); From 55dd5abf8701ac1e4037fcaa0faaec3216a7476e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BCdiger=20zu=20Dohna?= Date: Fri, 28 May 2021 09:55:17 +0200 Subject: [PATCH 4/4] fix formatting --- .../main/java/io/smallrye/graphql/federation/api/Extends.java | 4 ++-- .../java/io/smallrye/graphql/federation/api/External.java | 2 +- .../io/smallrye/graphql/federation/api/FederatedSource.java | 4 ++-- .../src/main/java/io/smallrye/graphql/federation/api/Key.java | 2 +- .../java/io/smallrye/graphql/federation/api/Provides.java | 4 ++-- .../java/io/smallrye/graphql/federation/api/Requires.java | 4 ++-- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Extends.java b/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Extends.java index ce6047790..d17fd1648 100644 --- a/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Extends.java +++ b/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Extends.java @@ -6,9 +6,9 @@ import java.lang.annotation.Retention; -import io.smallrye.common.annotation.Experimental; import org.eclipse.microprofile.graphql.Description; +import io.smallrye.common.annotation.Experimental; import io.smallrye.graphql.api.Directive; /** directive @extends on OBJECT | INTERFACE */ @@ -17,6 +17,6 @@ "Apollo Federation supports using an @extends directive in place of extend type to annotate type references.") @Retention(RUNTIME) @Experimental("SmallRye GraphQL Federation is still subject to change. " + - "Additionally, this annotation is currently only a directive without explicit support from the extension.") + "Additionally, this annotation is currently only a directive without explicit support from the extension.") public @interface Extends { } diff --git a/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/External.java b/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/External.java index 6b32df848..ec5d81e74 100644 --- a/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/External.java +++ b/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/External.java @@ -5,9 +5,9 @@ import java.lang.annotation.Retention; -import io.smallrye.common.annotation.Experimental; import org.eclipse.microprofile.graphql.Description; +import io.smallrye.common.annotation.Experimental; import io.smallrye.graphql.api.Directive; /** directive @external on FIELD_DEFINITION */ diff --git a/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/FederatedSource.java b/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/FederatedSource.java index 34669535a..b47fc767e 100644 --- a/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/FederatedSource.java +++ b/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/FederatedSource.java @@ -1,13 +1,13 @@ package io.smallrye.graphql.federation.api; -import io.smallrye.common.annotation.Experimental; - import static java.lang.annotation.ElementType.PARAMETER; import static java.lang.annotation.RetentionPolicy.RUNTIME; import java.lang.annotation.Retention; import java.lang.annotation.Target; +import io.smallrye.common.annotation.Experimental; + /** * A federated resolver method is the federated equivalent to a 'local' resolver method (with a parameter annotated as * {@link org.eclipse.microprofile.graphql.Source @Source}), i.e. it adds a field with the name of the method diff --git a/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Key.java b/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Key.java index e2c314c62..908e7a979 100644 --- a/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Key.java +++ b/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Key.java @@ -6,10 +6,10 @@ import java.lang.annotation.Retention; -import io.smallrye.common.annotation.Experimental; import org.eclipse.microprofile.graphql.Description; import org.eclipse.microprofile.graphql.NonNull; +import io.smallrye.common.annotation.Experimental; import io.smallrye.graphql.api.Directive; /** directive @key(fields: _FieldSet!) on OBJECT | INTERFACE */ diff --git a/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Provides.java b/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Provides.java index 4a37814ba..eae1baf23 100644 --- a/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Provides.java +++ b/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Provides.java @@ -5,10 +5,10 @@ import java.lang.annotation.Retention; -import io.smallrye.common.annotation.Experimental; import org.eclipse.microprofile.graphql.Description; import org.eclipse.microprofile.graphql.NonNull; +import io.smallrye.common.annotation.Experimental; import io.smallrye.graphql.api.Directive; /** directive @provides(fields: _FieldSet!) on FIELD_DEFINITION */ @@ -16,7 +16,7 @@ @Description("When resolving the annotated field, this service can provide additional, normally `@external` fields.") @Retention(RUNTIME) @Experimental("SmallRye GraphQL Federation is still subject to change. " + - "Additionally, this annotation is currently only a directive without explicit support from the extension.") + "Additionally, this annotation is currently only a directive without explicit support from the extension.") public @interface Provides { @NonNull String[] fields(); diff --git a/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Requires.java b/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Requires.java index 030dae908..1f4c39461 100644 --- a/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Requires.java +++ b/server/federation/api/src/main/java/io/smallrye/graphql/federation/api/Requires.java @@ -5,10 +5,10 @@ import java.lang.annotation.Retention; -import io.smallrye.common.annotation.Experimental; import org.eclipse.microprofile.graphql.Description; import org.eclipse.microprofile.graphql.NonNull; +import io.smallrye.common.annotation.Experimental; import io.smallrye.graphql.api.Directive; /** directive @requires(fields: _FieldSet!) on FIELD_DEFINITION */ @@ -17,7 +17,7 @@ "even when the client didn't request them.") @Retention(RUNTIME) @Experimental("SmallRye GraphQL Federation is still subject to change. " + - "Additionally, this annotation is currently only a directive without explicit support from the extension.") + "Additionally, this annotation is currently only a directive without explicit support from the extension.") public @interface Requires { @NonNull String[] fields();