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 3123a0d64..971dd5525 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 @@ -590,6 +590,7 @@ private static Map getAnnotationsWithFilter(org.jbo public static final DotName ERROR_CODE = DotName.createSimple("io.smallrye.graphql.api.ErrorCode"); 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 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 0d819a3f1..39566d1a4 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 @@ -386,6 +386,9 @@ private void addOperations(Optional group, Schema schema, List queries = new HashSet<>(); private Set mutations = new HashSet<>(); private Set subscriptions = new HashSet<>(); + private Set resolvers = new HashSet<>(); private Map> groupedQueries = new HashMap<>(); private Map> groupedMutations = new HashMap<>(); @@ -95,6 +96,22 @@ public boolean hasSubscriptions() { return !this.subscriptions.isEmpty(); } + public Set getResolvers() { + return resolvers; + } + + public void setResolvers(Set resolvers) { + this.resolvers = resolvers; + } + + public void addResolver(Operation resolver) { + this.resolvers.add(resolver); + } + + public boolean hasResolvers() { + return !this.resolvers.isEmpty(); + } + public Map> getGroupedQueries() { return groupedQueries; } diff --git a/docs/federation.md b/docs/federation.md index 7f09c763f..3ed10fcf7 100644 --- a/docs/federation.md +++ b/docs/federation.md @@ -84,3 +84,122 @@ public class Prices { It is crucial that the sequence of argument list matches with the order of result list. Currently, the name of the Argument `id` must match with the property name in the type. +## Federation Reference Resolver + +In federation you also may want extend external type by some fields, without publishing queries into schema. You can do it using @Resolver + +```java +@Extends +@Key(fields = @FieldSet("upc")) +public final class Product { + @External + @NonNull + private String upc; + @External + private Integer weight; + @External + private Integer price; + private Boolean inStock; + @Requires(fields = @FieldSet("price weight")) + private Integer shippingPrice; +} + +@GraphQLApi +public class Api { + @Query // 0 query, that will be added into schema + public Product findByUPC(String upc) { + return new Product(upc , ...etc); + } + + @Resolver // 1 You dont receive external fields price weight here, just key + public Product resolveByUPC(String upc) { + return new Product(upc , ...etc); + } + + @Resolver // 2 The order of variables doesn't matter + public Product resolveByUPCForShipping(int price, String upc, @Name("weight") int someWeight) { + return new Product(upc , someWeight, price, (price * someWeight) /*calculate shippingPrice */, ...etc); + } + + @Resolver // 3 + public Product resolveByUPCForSource(int price, String upc) { + return new Product(upc, price, ...etc); + } + + @Requires(fields = @FieldSet("price")) + public int anotherWeight(@Source Product product) { + return product.price() * 2; + } +} +``` + +Will be generated next schema +``` +type Product @extends @key(fields : "upc") { + anotherWeight: Int! @requires(fields : "price") + inStock: Boolean + price: Int @external + shippingPrice: Int @requires(fields : "price weight") + upc: String! @external + weight: Int @external +} + +type Query { + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! +} +``` + +These methods will only be available to the federation router, which send next request +``` +// request 1 +query { + _entities(representations: [{ + "__typename": "Product", + "upc": "1" // just id key + }]) { + __typename + ... on Product { + inStock + } + } +} + +// request 2 +query { + _entities(representations: [{ + "__typename": "Product", + "upc": "1", // id key + "price": 100, // shippingPrice requires this field + "weight": 100 // shippingPrice requires this field + }]) { + __typename + ... on Product { + inStock + shippingPrice + } + } +} + +// request 3 +query { + _entities(representations: [{ + "__typename": "Product", + "upc": "2", + "price": 1299 // anotherWeight requires this field + } + ]) { + __typename + ... on Product { + anotherWeight + } + } +} +``` + +Unfortunately, you will have to make separate methods with different `@External` parameters. + +It is not currently possible to combine them into one separate type. + +You also can using @Query (if you want add queries into schema) or @Resolver (requests 0 and 1). +And if it was request `_entities` - @Resolvers methods are checked first (they have higher priority). diff --git a/server/api/src/main/java/io/smallrye/graphql/api/federation/Resolver.java b/server/api/src/main/java/io/smallrye/graphql/api/federation/Resolver.java new file mode 100644 index 000000000..98e617a14 --- /dev/null +++ b/server/api/src/main/java/io/smallrye/graphql/api/federation/Resolver.java @@ -0,0 +1,15 @@ +package io.smallrye.graphql.api.federation; + +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import io.smallrye.common.annotation.Experimental; + +@Target(ElementType.METHOD) +@Retention(RUNTIME) +@Experimental("Resolver method without creating query method") +public @interface Resolver { +} diff --git a/server/implementation-servlet/src/main/java/io/smallrye/graphql/entry/http/IndexInitializer.java b/server/implementation-servlet/src/main/java/io/smallrye/graphql/entry/http/IndexInitializer.java index 92610c307..6a4a189ce 100644 --- a/server/implementation-servlet/src/main/java/io/smallrye/graphql/entry/http/IndexInitializer.java +++ b/server/implementation-servlet/src/main/java/io/smallrye/graphql/entry/http/IndexInitializer.java @@ -41,6 +41,7 @@ import io.smallrye.graphql.api.federation.Override; import io.smallrye.graphql.api.federation.Provides; import io.smallrye.graphql.api.federation.Requires; +import io.smallrye.graphql.api.federation.Resolver; import io.smallrye.graphql.api.federation.Shareable; import io.smallrye.graphql.api.federation.Tag; import io.smallrye.graphql.api.federation.link.Import; @@ -126,6 +127,7 @@ private IndexView createCustomIndex() { indexer.index(convertClassToInputStream(Shareable.class)); indexer.index(convertClassToInputStream(Tag.class)); indexer.index(convertClassToInputStream(OneOf.class)); + indexer.index(convertClassToInputStream(Resolver.class)); } catch (IOException ex) { throw new RuntimeException(ex); } 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 1eaf74ff6..0b55347dd 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 @@ -30,6 +30,7 @@ import com.apollographql.federation.graphqljava.Federation; +import graphql.Scalars; import graphql.introspection.Introspection.DirectiveLocation; import graphql.schema.Coercing; import graphql.schema.DataFetcher; @@ -124,7 +125,7 @@ public static GraphQLSchema bootstrap(Schema schema) { } public static GraphQLSchema bootstrap(Schema schema, boolean skipInjectionValidation) { - if (schema != null && (schema.hasOperations())) { + if (schema != null && (schema.hasOperations() || Config.get().isFederationEnabled())) { Bootstrap bootstrap = new Bootstrap(schema, skipInjectionValidation); bootstrap.generateGraphQLSchema(); return bootstrap.graphQLSchema; @@ -185,7 +186,7 @@ private void generateGraphQLSchema() { createGraphQLObjectTypes(); createGraphQLInputObjectTypes(); - addQueries(schemaBuilder); + GraphQLObjectType queryRootType = addQueries(schemaBuilder); addMutations(schemaBuilder); addSubscriptions(schemaBuilder); schemaBuilder.withSchemaAppliedDirectives(Arrays.stream( @@ -214,9 +215,23 @@ private void generateGraphQLSchema() { if (Config.get().isFederationEnabled()) { log.enableFederation(); + + if (!(schema.hasQueries() || schema.hasResolvers() || schema.hasMutations() || schema.hasSubscriptions())) { + throw new RuntimeException("You must define at least one method marked with one of the annotations - " + + "@Query, @Mutation, @Subscription, @Resolver. An empty diagram has no meaning"); + } + + // hack! For schema build success if queries are empty. + // It will be overrides in Federation transformation + addDummySdlQuery(schemaBuilder, queryRootType); + + // Build reference resolvers type, without adding to schema (just for federation) + GraphQLObjectType resolversType = buildResolvers(); + GraphQLSchema rawSchema = schemaBuilder.build(); this.graphQLSchema = Federation.transform(rawSchema) - .fetchEntities(new FederationDataFetcher(rawSchema.getQueryType(), rawSchema.getCodeRegistry())) + .fetchEntities( + new FederationDataFetcher(resolversType, rawSchema.getQueryType(), rawSchema.getCodeRegistry())) .resolveEntityType(fetchEntityType()) .setFederation2(true) .build(); @@ -225,6 +240,35 @@ private void generateGraphQLSchema() { } } + private void addDummySdlQuery(GraphQLSchema.Builder schemaBuilder, GraphQLObjectType queryRootType) { + GraphQLObjectType type = GraphQLObjectType.newObject() + .name("_Service") + .field(GraphQLFieldDefinition + .newFieldDefinition().name("sdl") + .type(new GraphQLNonNull(Scalars.GraphQLString)) + .build()) + .build(); + + GraphQLFieldDefinition field = GraphQLFieldDefinition.newFieldDefinition() + .name("_service") + .type(GraphQLNonNull.nonNull(type)) + .build(); + + GraphQLObjectType.Builder newQueryType = GraphQLObjectType.newObject(queryRootType); + + newQueryType.field(field); + schemaBuilder.query(newQueryType.build()); + } + + private GraphQLObjectType buildResolvers() { + GraphQLObjectType.Builder queryBuilder = GraphQLObjectType.newObject() + .name("Resolver"); + if (schema.hasResolvers()) { + addRootObject(queryBuilder, schema.getResolvers(), "Resolver"); + } + return queryBuilder.build(); + } + private TypeResolver fetchEntityType() { return env -> { Object src = env.getObject(); @@ -326,7 +370,7 @@ private void createGraphQLDirectiveType(DirectiveType directiveType) { directiveTypes.add(directiveBuilder.build()); } - private void addQueries(GraphQLSchema.Builder schemaBuilder) { + private GraphQLObjectType addQueries(GraphQLSchema.Builder schemaBuilder) { GraphQLObjectType.Builder queryBuilder = GraphQLObjectType.newObject() .name(QUERY) .description(QUERY_DESCRIPTION); @@ -340,6 +384,7 @@ private void addQueries(GraphQLSchema.Builder schemaBuilder) { GraphQLObjectType query = queryBuilder.build(); schemaBuilder.query(query); + return query; } private void addMutations(GraphQLSchema.Builder schemaBuilder) { diff --git a/server/implementation/src/main/java/io/smallrye/graphql/bootstrap/FederationDataFetcher.java b/server/implementation/src/main/java/io/smallrye/graphql/bootstrap/FederationDataFetcher.java index 43d14724a..279da5937 100644 --- a/server/implementation/src/main/java/io/smallrye/graphql/bootstrap/FederationDataFetcher.java +++ b/server/implementation/src/main/java/io/smallrye/graphql/bootstrap/FederationDataFetcher.java @@ -38,10 +38,13 @@ public class FederationDataFetcher implements DataFetcher cache = new HashMap<>(); - public FederationDataFetcher(GraphQLObjectType queryType, GraphQLCodeRegistry codeRegistry) { + public FederationDataFetcher(GraphQLObjectType resolversType, GraphQLObjectType queryType, + GraphQLCodeRegistry codeRegistry) { + this.resolversType = resolversType; this.queryType = queryType; this.codeRegistry = codeRegistry; } @@ -103,6 +106,11 @@ && matchesArguments(typeAndArgumentNames, definition)) { } private TypeFieldWrapper findBatchFieldDefinition(TypeAndArgumentNames typeAndArgumentNames) { + for (GraphQLFieldDefinition field : resolversType.getFields()) { + if (matchesReturnTypeList(field, typeAndArgumentNames.type) && matchesArguments(typeAndArgumentNames, field)) { + return new TypeFieldWrapper(resolversType, field); + } + } for (GraphQLFieldDefinition field : queryType.getFields()) { if (matchesReturnTypeList(field, typeAndArgumentNames.type) && matchesArguments(typeAndArgumentNames, field)) { return new TypeFieldWrapper(queryType, field); @@ -119,6 +127,11 @@ private TypeFieldWrapper findBatchFieldDefinition(TypeAndArgumentNames typeAndAr } private TypeFieldWrapper findFieldDefinition(TypeAndArgumentNames typeAndArgumentNames) { + for (GraphQLFieldDefinition field : resolversType.getFields()) { + if (matchesReturnType(field, typeAndArgumentNames.type) && matchesArguments(typeAndArgumentNames, field)) { + return new TypeFieldWrapper(resolversType, field); + } + } for (GraphQLFieldDefinition field : queryType.getFields()) { if (matchesReturnType(field, typeAndArgumentNames.type) && matchesArguments(typeAndArgumentNames, field)) { return new TypeFieldWrapper(queryType, field); @@ -131,7 +144,6 @@ private TypeFieldWrapper findFieldDefinition(TypeAndArgumentNames typeAndArgumen return typeFieldWrapper; } } - throw new RuntimeException( "no query found for " + typeAndArgumentNames.type + " by " + typeAndArgumentNames.argumentNames); } diff --git a/server/implementation/src/test/java/io/smallrye/graphql/execution/ResolverTest.java b/server/implementation/src/test/java/io/smallrye/graphql/execution/ResolverTest.java new file mode 100644 index 000000000..278f0585f --- /dev/null +++ b/server/implementation/src/test/java/io/smallrye/graphql/execution/ResolverTest.java @@ -0,0 +1,155 @@ +package io.smallrye.graphql.execution; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.io.IOException; +import java.io.InputStream; +import java.util.stream.Stream; + +import jakarta.json.JsonObject; +import jakarta.json.JsonString; +import jakarta.json.JsonValue; + +import org.jboss.jandex.IndexView; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import graphql.schema.GraphQLSchema; +import io.smallrye.graphql.api.Directive; +import io.smallrye.graphql.api.federation.Extends; +import io.smallrye.graphql.api.federation.External; +import io.smallrye.graphql.api.federation.Key; +import io.smallrye.graphql.api.federation.Requires; +import io.smallrye.graphql.bootstrap.Bootstrap; +import io.smallrye.graphql.schema.SchemaBuilder; +import io.smallrye.graphql.schema.model.Schema; +import io.smallrye.graphql.spi.config.Config; +import io.smallrye.graphql.test.resolver.ExtendedApi; +import io.smallrye.graphql.test.resolver.ExtendedType; + +/** + * Test for Federated namespaces + */ +public class ResolverTest { + private static final TestConfig config = (TestConfig) Config.get(); + private static ExecutionService executionService; + + @AfterAll + static void afterAll() { + config.reset(); + config.federationEnabled = false; + System.setProperty("smallrye.graphql.federation.enabled", "false"); + } + + @BeforeAll + static void beforeAll() { + config.federationEnabled = true; + System.setProperty("smallrye.graphql.federation.enabled", "true"); + + IndexView index = buildIndex(Directive.class, Key.class, External.class, Key.Keys.class, + Extends.class, Requires.class, ExtendedType.class, ExtendedApi.class); + + GraphQLSchema graphQLSchema = createGraphQLSchema(index); + Schema schema = SchemaBuilder.build(index); + executionService = new ExecutionService(graphQLSchema, schema); + } + + private static IndexView buildIndex(Class... classes) { + org.jboss.jandex.Indexer indexer = new org.jboss.jandex.Indexer(); + Stream.of(classes).forEach(cls -> index(indexer, cls)); + return indexer.complete(); + } + + private static InputStream getResourceStream(Class type) { + String name = type.getName().replace(".", "/") + ".class"; + return Thread.currentThread().getContextClassLoader().getResourceAsStream(name); + } + + private static void index(org.jboss.jandex.Indexer indexer, Class cls) { + try { + indexer.index(getResourceStream(cls)); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private static GraphQLSchema createGraphQLSchema(IndexView index) { + Schema schema = SchemaBuilder.build(index); + assertNotNull(schema, "Schema should not be null"); + GraphQLSchema graphQLSchema = Bootstrap.bootstrap(schema, true); + assertNotNull(graphQLSchema, "GraphQLSchema should not be null"); + return graphQLSchema; + } + + private static JsonObject executeAndGetResult(String graphQL) { + JsonObjectResponseWriter jsonObjectResponseWriter = new JsonObjectResponseWriter(graphQL); + jsonObjectResponseWriter.logInput(); + executionService.executeSync(jsonObjectResponseWriter.getInput(), jsonObjectResponseWriter); + jsonObjectResponseWriter.logOutput(); + return jsonObjectResponseWriter.getOutput(); + } + + @Test + public void findByIdTest() { + JsonObject jsonObject = executeAndGetResult(TEST_ID_QUERY); + assertNotNull(jsonObject); + + JsonValue jsonValue = jsonObject.getJsonObject("data") + .getJsonArray("_entities") + .getJsonObject(0) + .get("id"); + assertEquals(((JsonString) jsonValue).getString(), "id"); + + jsonValue = jsonObject.getJsonObject("data") + .getJsonArray("_entities") + .getJsonObject(0) + .get("value"); + assertNull(jsonValue); + } + + @Test + public void extendsTest() { + JsonObject jsonObject = executeAndGetResult(TEST_ID_NAME_KEY_QUERY); + assertNotNull(jsonObject); + + JsonValue jsonValue = jsonObject.getJsonObject("data") + .getJsonArray("_entities") + .getJsonObject(0) + .get("id"); + assertEquals(((JsonString) jsonValue).getString(), "id"); + + jsonValue = jsonObject.getJsonObject("data") + .getJsonArray("_entities") + .getJsonObject(0) + .get("value"); + assertEquals(((JsonString) jsonValue).getString(), "idnamekey"); + } + + private static final String TEST_ID_QUERY = "query {\n" + + "_entities(\n" + + " representations: { id: \"id\", __typename: \"ExtendedType\" }\n" + + ") {\n" + + " __typename\n" + + " ... on ExtendedType {\n" + + " id\n" + + " }\n" + + " }\n" + + "}"; + + private static final String TEST_ID_NAME_KEY_QUERY = "query {\n" + + "_entities(\n" + + " representations: { id: \"id\", name: \"name\", key: \"key\", __typename: \"ExtendedType\" }\n" + + ") {\n" + + " __typename\n" + + " ... on ExtendedType {\n" + + " id\n" + + " name\n" + + " key\n" + + " value\n" + + " }\n" + + " }\n" + + "}"; +} diff --git a/server/implementation/src/test/java/io/smallrye/graphql/test/resolver/ExtendedApi.java b/server/implementation/src/test/java/io/smallrye/graphql/test/resolver/ExtendedApi.java new file mode 100644 index 000000000..e224c570e --- /dev/null +++ b/server/implementation/src/test/java/io/smallrye/graphql/test/resolver/ExtendedApi.java @@ -0,0 +1,25 @@ +package io.smallrye.graphql.test.resolver; + +import org.eclipse.microprofile.graphql.GraphQLApi; + +import io.smallrye.graphql.api.federation.Resolver; + +@GraphQLApi +public class ExtendedApi { + @Resolver + public ExtendedType extendedTypeById(String id) { + ExtendedType extendedType = new ExtendedType(); + extendedType.setId(id); + return extendedType; + } + + @Resolver + public ExtendedType extendedTypeByIdNameKey(String id, String name, String key) { + ExtendedType extendedType = new ExtendedType(); + extendedType.setId(id); + extendedType.setName(name); + extendedType.setKey(key); + extendedType.setValue(id + name + key); + return extendedType; + } +} diff --git a/server/implementation/src/test/java/io/smallrye/graphql/test/resolver/ExtendedType.java b/server/implementation/src/test/java/io/smallrye/graphql/test/resolver/ExtendedType.java new file mode 100644 index 000000000..0d183f209 --- /dev/null +++ b/server/implementation/src/test/java/io/smallrye/graphql/test/resolver/ExtendedType.java @@ -0,0 +1,55 @@ +package io.smallrye.graphql.test.resolver; + +import io.smallrye.graphql.api.federation.Extends; +import io.smallrye.graphql.api.federation.External; +import io.smallrye.graphql.api.federation.FieldSet; +import io.smallrye.graphql.api.federation.Key; +import io.smallrye.graphql.api.federation.Requires; + +@Extends +@Key(fields = @FieldSet("id")) +public class ExtendedType { + @External + private String id; + + @External + private String name; + + @External + private String key; + + @Requires(fields = @FieldSet("name key")) + private String value; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getKey() { + return key; + } + + public void setKey(String key) { + this.key = key; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } +} diff --git a/tools/maven-plugin/src/main/java/io/smallrye/graphql/mavenplugin/GenerateSchemaMojo.java b/tools/maven-plugin/src/main/java/io/smallrye/graphql/mavenplugin/GenerateSchemaMojo.java index f6fb2633a..90d2ad4de 100644 --- a/tools/maven-plugin/src/main/java/io/smallrye/graphql/mavenplugin/GenerateSchemaMojo.java +++ b/tools/maven-plugin/src/main/java/io/smallrye/graphql/mavenplugin/GenerateSchemaMojo.java @@ -254,7 +254,9 @@ private String generateSchema(IndexView index, boolean enableFederation) { GraphQLSchema graphQLSchema = Bootstrap.bootstrap(internalSchema, true); if (graphQLSchema != null && enableFederation) { graphQLSchema = Federation.transform(graphQLSchema) - .fetchEntities(new FederationDataFetcher(graphQLSchema.getQueryType(), graphQLSchema.getCodeRegistry())) + .fetchEntities(new FederationDataFetcher( + GraphQLObjectType.newObject().name("Resolver").build(), + graphQLSchema.getQueryType(), graphQLSchema.getCodeRegistry())) .resolveEntityType(fetchEntityType()) .setFederation2(true) .build();