From a9a8d0b745965fe664b1b502ae4e932b40c1d2b9 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Mon, 3 Jun 2024 18:27:19 +0200 Subject: [PATCH] Add fragments support in GraphQlTester This commit adds support for GraphQL fragments with `GraphQlTester`. Fragments allow to avoid repetition in GraphQL requests by reusing field selection sets. For example, the "releases" fragment can be reused in multiple queries and make the overall document shorter: ``` query frameworkReleases { project(slug: "spring-framework") { name ...releases } } query graphqlReleases { project(slug: "spring-graphql") { name ...releases } } fragment releases on Project { releases { version } } ``` With this change, `GraphQlTester` accepts fragments as `String` or can load them by their name using the configured `DocumentSource`, similarly to the document support. Closes gh-964 --- .../modules/ROOT/pages/client.adoc | 49 +++++++++++++ .../test/tester/DefaultGraphQlTester.java | 21 +++++- .../graphql/test/tester/GraphQlTester.java | 26 ++++++- .../test/tester/GraphQlTesterTests.java | 72 +++++++++++++++++++ .../resources/graphql-test/friends.graphql | 3 + .../test/resources/graphql-test/me.graphql | 1 + 6 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 spring-graphql-test/src/test/resources/graphql-test/friends.graphql create mode 100644 spring-graphql-test/src/test/resources/graphql-test/me.graphql diff --git a/spring-graphql-docs/modules/ROOT/pages/client.adoc b/spring-graphql-docs/modules/ROOT/pages/client.adoc index 23ad772c..2270702c 100644 --- a/spring-graphql-docs/modules/ROOT/pages/client.adoc +++ b/spring-graphql-docs/modules/ROOT/pages/client.adoc @@ -476,6 +476,55 @@ You can then: <1> Load the document from "projectReleases.graphql" <2> Provide variable values. + +This approach also works for loading fragments for your queries. +Fragments are reusable field selection sets that avoid repetition in a request document. +For example, we can use a `...releases` fragment in multiple queries: + +[source,graphql,indent=0,subs="verbatim,quotes"] +.src/main/resources/graphql-documents/projectReleases.graphql +---- + query frameworkReleases { + project(slug: "spring-framework") { + name + ...releases + } + } + query graphqlReleases { + project(slug: "spring-graphql") { + name + ...releases + } + } +---- + +This fragment can be defined in a separate file for reuse: + +[source,graphql,indent=0,subs="verbatim,quotes"] +.src/main/resources/graphql-documents/releases.graphql +---- + fragment releases on Project { + releases { + version + } + } +---- + + +You can then send this fragment along the query document: + +[source,java,indent=0,subs="verbatim,quotes"] +---- + Project project = graphQlClient.documentName("projectReleases") <1> + .fragmentName("releases") <2> + .retrieveSync() + .toEntity(Project.class); +---- +<1> Load the document from "projectReleases.graphql" +<2> Load the fragment from "releases.graphql" and append it to the document + + + The "JS GraphQL" plugin for IntelliJ supports GraphQL query files with code completion. You can use the `GraphQlClient` xref:client.adoc#client.graphqlclient.builder[Builder] to customize the diff --git a/spring-graphql-test/src/main/java/org/springframework/graphql/test/tester/DefaultGraphQlTester.java b/spring-graphql-test/src/main/java/org/springframework/graphql/test/tester/DefaultGraphQlTester.java index a4844ec2..1c58a5ef 100644 --- a/spring-graphql-test/src/main/java/org/springframework/graphql/test/tester/DefaultGraphQlTester.java +++ b/spring-graphql-test/src/main/java/org/springframework/graphql/test/tester/DefaultGraphQlTester.java @@ -123,6 +123,8 @@ final class DefaultGraphQlTester implements GraphQlTester { @Nullable private String operationName; + List fragments = new ArrayList<>(); + private final Map variables = new LinkedHashMap<>(); private final Map extensions = new LinkedHashMap<>(); @@ -138,6 +140,21 @@ final class DefaultGraphQlTester implements GraphQlTester { return this; } + @Override + public DefaultRequest fragment(String fragment) { + Assert.hasText(fragment, "Fragment should not be empty"); + this.fragments.add(fragment); + return this; + } + + @Override + public DefaultRequest fragmentName(String fragmentName) { + String fragment = DefaultGraphQlTester.this.documentSource.getDocument(fragmentName) + .block(DefaultGraphQlTester.this.responseTimeout); + Assert.hasText(fragment, "DocumentSource completed empty for fragment " + fragmentName); + return this.fragment(fragment); + } + @Override public DefaultRequest variable(String name, @Nullable Object value) { this.variables.put(name, value); @@ -176,7 +193,9 @@ final class DefaultGraphQlTester implements GraphQlTester { } private GraphQlRequest request() { - return new DefaultGraphQlRequest(this.document, this.operationName, this.variables, this.extensions); + StringBuilder document = new StringBuilder(this.document); + this.fragments.forEach(document::append); + return new DefaultGraphQlRequest(document.toString(), this.operationName, this.variables, this.extensions); } private DefaultResponse mapResponse(GraphQlResponse response, GraphQlRequest request) { diff --git a/spring-graphql-test/src/main/java/org/springframework/graphql/test/tester/GraphQlTester.java b/spring-graphql-test/src/main/java/org/springframework/graphql/test/tester/GraphQlTester.java index 30d770d6..e6d4d4df 100644 --- a/spring-graphql-test/src/main/java/org/springframework/graphql/test/tester/GraphQlTester.java +++ b/spring-graphql-test/src/main/java/org/springframework/graphql/test/tester/GraphQlTester.java @@ -49,14 +49,14 @@ import org.springframework.lang.Nullable; * * * @author Rossen Stoyanchev + * @author Brian Clozel * @since 1.0.0 */ public interface GraphQlTester { /** * Start defining a GraphQL request with the given document, which is the - * textual representation of an operation (or operations) to perform, - * including selection sets and fragments. + * textual representation of an operation (or operations) to perform. * @param document the document for the request * @return spec for response assertions * @throws AssertionError if the response status is not 200 (OK) @@ -145,6 +145,28 @@ public interface GraphQlTester { */ T operationName(@Nullable String name); + /** + * Append the given fragment section to the {@link #document(String) request document}. + * A fragment describes a selection of fields to be included in the query when needed + * and is defined with the {@code fragment} keyword. + * @param fragment the fragment definition + * @return this request spec + * @since 1.3 + * @see Fragments specification + */ + T fragment(String fragment); + + /** + * Variant of {@link #fragment(String)} that uses the given key to resolve + * the GraphQL fragment document from a file with the help of the configured + * {@link Builder#documentSource(DocumentSource) DocumentSource}. + * @param fragmentName the name of the fragment to append + * @return this request spec + * @throws IllegalArgumentException if the fragmentName cannot be resolved + * @since 1.3 + */ + T fragmentName(String fragmentName); + /** * Add a variable. * @param name the variable name diff --git a/spring-graphql-test/src/test/java/org/springframework/graphql/test/tester/GraphQlTesterTests.java b/spring-graphql-test/src/test/java/org/springframework/graphql/test/tester/GraphQlTesterTests.java index 2944d9e8..869e910c 100644 --- a/spring-graphql-test/src/test/java/org/springframework/graphql/test/tester/GraphQlTesterTests.java +++ b/spring-graphql-test/src/test/java/org/springframework/graphql/test/tester/GraphQlTesterTests.java @@ -32,15 +32,35 @@ import org.springframework.graphql.ExecutionGraphQlService; import org.springframework.graphql.GraphQlRequest; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; import static org.assertj.core.api.Assertions.assertThatThrownBy; /** * Tests for {@link GraphQlTester} with a mock {@link ExecutionGraphQlService}. * * @author Rossen Stoyanchev + * @author Brian Clozel */ public class GraphQlTesterTests extends GraphQlTesterTestSupport { + @Test + void missingDocumentByName() { + String document = "{me {name, friends}}"; + getGraphQlService().setDataAsJson(document, "{\"me\": {\"name\":\"Luke Skywalker\", \"friends\":[]}}"); + + assertThatIllegalStateException().isThrownBy(() -> graphQlTester().documentName("unknown").execute()) + .withMessageContaining("Failed to find document, name='unknown', under location(s)=[class path resource [graphql-test/]]"); + } + + @Test + void resolveDocumentByName() { + String document = "{me {name, friends}}"; + getGraphQlService().setDataAsJson(document, "{\"me\": {\"name\":\"Luke Skywalker\", \"friends\":[]}}"); + + GraphQlTester.Response response = graphQlTester().documentName("me").execute(); + response.path("me.name").hasValue(); + } + @Test void hasValue() { @@ -243,6 +263,58 @@ public class GraphQlTesterTests extends GraphQlTesterTestSupport { assertThat(request.getVariables()).containsEntry("keyOnly", null); } + @Test + void documentWithFragment() { + String document = """ + query meQuery { + me {name, ...friendsField} + } + """; + String fragment = + """ + fragment friendsField on User { + friends + } + """; + getGraphQlService().setDataAsJson(document + fragment, "{\"me\": {\"name\":\"Luke Skywalker\", \"friends\":[]}}"); + + GraphQlTester.Response response = graphQlTester().document(document).fragment(fragment).execute(); + response.path("me.name").hasValue(); + + assertThat(getActualRequestDocument()).contains(document); + } + + @Test + void missingFragmentByName() { + String document = "{me {name, friends}}"; + getGraphQlService().setDataAsJson(document, "{\"me\": {\"name\":\"Luke Skywalker\", \"friends\":[]}}"); + + assertThatIllegalStateException().isThrownBy(() -> graphQlTester().document(document).fragmentName("unknown").execute()) + .withMessageContaining("Failed to find document, name='unknown', under location(s)=[class path resource [graphql-test/]]"); + } + + @Test + void documentWithFragmentName() { + String document = + """ + query meQuery { + me {name, ...friendsField} + } + """; + String fragment = + """ + fragment friendsField on User { + friends + } + """; + getGraphQlService().setDataAsJson(document + fragment, "{\"me\": {\"name\":\"Luke Skywalker\", \"friends\":[]}}"); + + GraphQlTester.Response response = graphQlTester().document(document).fragmentName("friends").execute(); + response.path("me.name").hasValue(); + + assertThat(getActualRequestDocument()).contains(document); + } + @Test void variablesAsMap() { diff --git a/spring-graphql-test/src/test/resources/graphql-test/friends.graphql b/spring-graphql-test/src/test/resources/graphql-test/friends.graphql new file mode 100644 index 00000000..129e41dd --- /dev/null +++ b/spring-graphql-test/src/test/resources/graphql-test/friends.graphql @@ -0,0 +1,3 @@ +fragment friendsField on User { + friends +} diff --git a/spring-graphql-test/src/test/resources/graphql-test/me.graphql b/spring-graphql-test/src/test/resources/graphql-test/me.graphql new file mode 100644 index 00000000..9614385c --- /dev/null +++ b/spring-graphql-test/src/test/resources/graphql-test/me.graphql @@ -0,0 +1 @@ +{me {name, friends}} \ No newline at end of file