diff --git a/graphql-spring-boot-starter/src/main/java/org/springframework/graphql/boot/GraphQlWebFluxAutoConfiguration.java b/graphql-spring-boot-starter/src/main/java/org/springframework/graphql/boot/GraphQlWebFluxAutoConfiguration.java index 349d6a20..dfbd1c8b 100644 --- a/graphql-spring-boot-starter/src/main/java/org/springframework/graphql/boot/GraphQlWebFluxAutoConfiguration.java +++ b/graphql-spring-boot-starter/src/main/java/org/springframework/graphql/boot/GraphQlWebFluxAutoConfiguration.java @@ -36,6 +36,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceLoader; import org.springframework.graphql.GraphQlService; +import org.springframework.graphql.data.method.AnnotatedDataFetcherRegistrar; import org.springframework.graphql.execution.GraphQlSource; import org.springframework.graphql.web.WebGraphQlHandler; import org.springframework.graphql.web.WebInterceptor; @@ -73,6 +74,18 @@ public class GraphQlWebFluxAutoConfiguration { private static final Log logger = LogFactory.getLog(GraphQlWebFluxAutoConfiguration.class); + @Bean + public AnnotatedDataFetcherRegistrar dataFetcherRegistrar(ServerCodecConfigurer configurer) { + AnnotatedDataFetcherRegistrar registrar = new AnnotatedDataFetcherRegistrar(); + registrar.setServerCodecConfigurer(configurer); + return registrar; + } + + @Bean + public RuntimeWiringBuilderCustomizer annotatedDataFetcherRuntimeWiringCustomizer(AnnotatedDataFetcherRegistrar registrar) { + return registrar::register; + } + @Bean @ConditionalOnBean(GraphQlService.class) @ConditionalOnMissingBean diff --git a/graphql-spring-boot-starter/src/main/java/org/springframework/graphql/boot/GraphQlWebMvcAutoConfiguration.java b/graphql-spring-boot-starter/src/main/java/org/springframework/graphql/boot/GraphQlWebMvcAutoConfiguration.java index 5b7e976c..48b28568 100644 --- a/graphql-spring-boot-starter/src/main/java/org/springframework/graphql/boot/GraphQlWebMvcAutoConfiguration.java +++ b/graphql-spring-boot-starter/src/main/java/org/springframework/graphql/boot/GraphQlWebMvcAutoConfiguration.java @@ -40,6 +40,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceLoader; import org.springframework.graphql.GraphQlService; +import org.springframework.graphql.data.method.AnnotatedDataFetcherRegistrar; import org.springframework.graphql.execution.GraphQlSource; import org.springframework.graphql.execution.ThreadLocalAccessor; import org.springframework.graphql.web.WebGraphQlHandler; @@ -51,7 +52,7 @@ import org.springframework.graphql.web.webmvc.SchemaHandler; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; -import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.GenericHttpMessageConverter; import org.springframework.web.servlet.HandlerMapping; import org.springframework.web.servlet.function.RouterFunction; import org.springframework.web.servlet.function.RouterFunctions; @@ -80,6 +81,28 @@ public class GraphQlWebMvcAutoConfiguration { private static final Log logger = LogFactory.getLog(GraphQlWebMvcAutoConfiguration.class); + + @Bean + public AnnotatedDataFetcherRegistrar dataFetcherRegistrar(HttpMessageConverters converters) { + AnnotatedDataFetcherRegistrar registrar = new AnnotatedDataFetcherRegistrar(); + registrar.setJsonMessageConverter(getJsonConverter(converters)); + return registrar; + } + + @SuppressWarnings("unchecked") + private static GenericHttpMessageConverter getJsonConverter(HttpMessageConverters converters) { + return converters.getConverters().stream() + .filter((candidate) -> candidate.canRead(Map.class, MediaType.APPLICATION_JSON)) + .findFirst() + .map(converter -> (GenericHttpMessageConverter) converter) + .orElseThrow(() -> new IllegalStateException("No JSON converter")); + } + + @Bean + public RuntimeWiringBuilderCustomizer annotatedDataFetcherRuntimeWiringCustomizer(AnnotatedDataFetcherRegistrar registrar) { + return registrar::register; + } + @Bean @ConditionalOnBean(GraphQlService.class) @ConditionalOnMissingBean @@ -138,12 +161,7 @@ public class GraphQlWebMvcAutoConfiguration { public GraphQlWebSocketHandler graphQlWebSocketHandler(WebGraphQlHandler webGraphQlHandler, GraphQlProperties properties, HttpMessageConverters converters) { - HttpMessageConverter converter = converters.getConverters().stream() - .filter((candidate) -> candidate.canRead(Map.class, MediaType.APPLICATION_JSON)) - .findFirst() - .orElseThrow(() -> new IllegalStateException("No JSON converter")); - - return new GraphQlWebSocketHandler(webGraphQlHandler, converter, + return new GraphQlWebSocketHandler(webGraphQlHandler, getJsonConverter(converters), properties.getWebsocket().getConnectionInitTimeout()); } diff --git a/samples/webflux-security/src/main/java/io/spring/sample/graphql/SalaryController.java b/samples/webflux-security/src/main/java/io/spring/sample/graphql/SalaryController.java new file mode 100644 index 00000000..f8904a10 --- /dev/null +++ b/samples/webflux-security/src/main/java/io/spring/sample/graphql/SalaryController.java @@ -0,0 +1,58 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.spring.sample.graphql; + +import java.math.BigDecimal; +import java.util.List; + +import reactor.core.publisher.Mono; + +import org.springframework.graphql.data.method.annotation.Argument; +import org.springframework.graphql.data.method.annotation.SchemaMapping; +import org.springframework.graphql.data.method.annotation.GraphQlController; +import org.springframework.graphql.data.method.annotation.MutationMapping; +import org.springframework.graphql.data.method.annotation.QueryMapping; + +@GraphQlController +public class SalaryController { + + private final EmployeeService employeeService; + + private final SalaryService salaryService; + + public SalaryController(EmployeeService employeeService, SalaryService salaryService) { + this.employeeService = employeeService; + this.salaryService = salaryService; + } + + @QueryMapping + public List employees() { + return this.employeeService.getAllEmployees(); + } + + @SchemaMapping + public Mono salary(Employee employee) { + return this.salaryService.getSalaryForEmployee(employee); + } + + @MutationMapping + public void updateSalary(@Argument("input") SalaryInput salaryInput) { + String employeeId = salaryInput.getEmployeeId(); + BigDecimal salary = salaryInput.getNewSalary(); + this.salaryService.updateSalary(employeeId, salary); + } + +} diff --git a/samples/webflux-security/src/main/java/io/spring/sample/graphql/SalaryInput.java b/samples/webflux-security/src/main/java/io/spring/sample/graphql/SalaryInput.java new file mode 100644 index 00000000..27869b03 --- /dev/null +++ b/samples/webflux-security/src/main/java/io/spring/sample/graphql/SalaryInput.java @@ -0,0 +1,42 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.spring.sample.graphql; + +import java.math.BigDecimal; + +public class SalaryInput { + + private String employeeId; + + private BigDecimal newSalary; + + public String getEmployeeId() { + return employeeId; + } + + public void setEmployeeId(String employeeId) { + this.employeeId = employeeId; + } + + public BigDecimal getNewSalary() { + return newSalary; + } + + public void setNewSalary(BigDecimal newSalary) { + this.newSalary = newSalary; + } + +} diff --git a/samples/webflux-security/src/main/java/io/spring/sample/graphql/SampleWiring.java b/samples/webflux-security/src/main/java/io/spring/sample/graphql/SampleWiring.java deleted file mode 100644 index 59791790..00000000 --- a/samples/webflux-security/src/main/java/io/spring/sample/graphql/SampleWiring.java +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright 2002-2021 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.spring.sample.graphql; - -import java.math.BigDecimal; -import java.util.Map; - -import graphql.schema.idl.RuntimeWiring; - -import org.springframework.graphql.boot.RuntimeWiringBuilderCustomizer; -import org.springframework.stereotype.Component; - -@Component -public class SampleWiring implements RuntimeWiringBuilderCustomizer { - - final EmployeeService employeeService; - - final SalaryService salaryService; - - public SampleWiring(EmployeeService employeeService, SalaryService salaryService) { - this.employeeService = employeeService; - this.salaryService = salaryService; - } - - - @Override - public void customize(RuntimeWiring.Builder builder) { - builder.type("Query", wiring -> - wiring.dataFetcher("employees", env -> this.employeeService.getAllEmployees()) - ); - builder.type("Employee", wiring -> - wiring.dataFetcher("salary", env -> this.salaryService.getSalaryForEmployee(env.getSource())) - ); - builder.type("Mutation", wiring -> - wiring.dataFetcher("updateSalary", env -> { - Map input = env.getArgument("input"); - String employeeId = input.get("employeeId"); - BigDecimal newSalary = new BigDecimal(input.get("salary")); - this.salaryService.updateSalary(employeeId, newSalary); - return null; - }) - ); - } - -} diff --git a/samples/webflux-websocket/src/main/java/io/spring/sample/graphql/SampleController.java b/samples/webflux-websocket/src/main/java/io/spring/sample/graphql/SampleController.java new file mode 100644 index 00000000..6112178f --- /dev/null +++ b/samples/webflux-websocket/src/main/java/io/spring/sample/graphql/SampleController.java @@ -0,0 +1,54 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.spring.sample.graphql; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.graphql.data.method.annotation.GraphQlController; +import org.springframework.graphql.data.method.annotation.QueryMapping; +import org.springframework.graphql.data.method.annotation.SubscriptionMapping; + +@GraphQlController +public class SampleController { + + private final DataRepository repository; + + public SampleController(DataRepository dataRepository) { + this.repository = dataRepository; + } + + @QueryMapping + public String greeting() { + return this.repository.getBasic(); + } + + @QueryMapping + public Mono greetingMono() { + return this.repository.getGreeting(); + } + + @QueryMapping + public Flux greetingsFlux() { + return this.repository.getGreetings(); + } + + @SubscriptionMapping + public Flux greetings() { + return this.repository.getGreetingsStream(); + } + +} diff --git a/samples/webflux-websocket/src/main/java/io/spring/sample/graphql/SampleWiring.java b/samples/webflux-websocket/src/main/java/io/spring/sample/graphql/SampleWiring.java deleted file mode 100644 index 6264e9bd..00000000 --- a/samples/webflux-websocket/src/main/java/io/spring/sample/graphql/SampleWiring.java +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2002-2021 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.spring.sample.graphql; - -import graphql.schema.idl.RuntimeWiring; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.graphql.boot.RuntimeWiringBuilderCustomizer; -import org.springframework.stereotype.Component; - -@Component -public class SampleWiring implements RuntimeWiringBuilderCustomizer { - - private final DataRepository repository; - - public SampleWiring(@Autowired DataRepository dataRepository) { - this.repository = dataRepository; - } - - @Override - public void customize(RuntimeWiring.Builder wiringBuilder) { - wiringBuilder.type("Query", builder -> builder.dataFetcher("greeting", env -> this.repository.getBasic())); - wiringBuilder.type("Query", builder -> builder.dataFetcher("greetingMono", env -> this.repository.getGreeting())); - wiringBuilder.type("Query", builder -> builder.dataFetcher("greetingsFlux", env -> this.repository.getGreetings())); - wiringBuilder.type("Subscription", builder -> builder.dataFetcher("greetings", env -> this.repository.getGreetingsStream())); - } - -} diff --git a/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/SalaryController.java b/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/SalaryController.java new file mode 100644 index 00000000..8b8a63df --- /dev/null +++ b/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/SalaryController.java @@ -0,0 +1,56 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.spring.sample.graphql; + +import java.math.BigDecimal; +import java.util.List; + +import org.springframework.graphql.data.method.annotation.Argument; +import org.springframework.graphql.data.method.annotation.SchemaMapping; +import org.springframework.graphql.data.method.annotation.GraphQlController; +import org.springframework.graphql.data.method.annotation.MutationMapping; +import org.springframework.graphql.data.method.annotation.QueryMapping; + +@GraphQlController +public class SalaryController { + + private final EmployeeService employeeService; + + private final SalaryService salaryService; + + public SalaryController(EmployeeService employeeService, SalaryService salaryService) { + this.employeeService = employeeService; + this.salaryService = salaryService; + } + + @QueryMapping + public List employees() { + return this.employeeService.getAllEmployees(); + } + + @SchemaMapping + public BigDecimal salary(Employee employee) { + return this.salaryService.getSalaryForEmployee(employee); + } + + @MutationMapping + public void updateSalary(@Argument("input") SalaryInput salaryInput) { + String employeeId = salaryInput.getEmployeeId(); + BigDecimal salary = salaryInput.getNewSalary(); + this.salaryService.updateSalary(employeeId, salary); + } + +} diff --git a/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/SalaryInput.java b/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/SalaryInput.java new file mode 100644 index 00000000..27869b03 --- /dev/null +++ b/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/SalaryInput.java @@ -0,0 +1,42 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.spring.sample.graphql; + +import java.math.BigDecimal; + +public class SalaryInput { + + private String employeeId; + + private BigDecimal newSalary; + + public String getEmployeeId() { + return employeeId; + } + + public void setEmployeeId(String employeeId) { + this.employeeId = employeeId; + } + + public BigDecimal getNewSalary() { + return newSalary; + } + + public void setNewSalary(BigDecimal newSalary) { + this.newSalary = newSalary; + } + +} diff --git a/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/SampleWiring.java b/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/SampleWiring.java deleted file mode 100644 index c6332495..00000000 --- a/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/SampleWiring.java +++ /dev/null @@ -1,48 +0,0 @@ -package io.spring.sample.graphql; - -import java.math.BigDecimal; -import java.util.Map; - -import graphql.schema.idl.RuntimeWiring; - -import org.springframework.graphql.boot.RuntimeWiringBuilderCustomizer; -import org.springframework.stereotype.Component; - -@Component -public class SampleWiring implements RuntimeWiringBuilderCustomizer { - - final EmployeeService employeeService; - - final SalaryService salaryService; - - public SampleWiring(EmployeeService employeeService, SalaryService salaryService) { - this.employeeService = employeeService; - this.salaryService = salaryService; - } - - - @Override - public void customize(RuntimeWiring.Builder builder) { - builder.type("Query", wiringBuilder -> - wiringBuilder.dataFetcher("employees", env -> - employeeService.getAllEmployees() - ) - ); - builder.type("Employee", wiringBuilder -> - wiringBuilder.dataFetcher("salary", env -> { - Employee employee = env.getSource(); - return salaryService.getSalaryForEmployee(employee); - }) - ); - builder.type("Mutation", wiringBuilder -> - wiringBuilder.dataFetcher("updateSalary", env -> { - Map input = env.getArgument("input"); - String employeeId = input.get("employeeId"); - BigDecimal newSalary = new BigDecimal(input.get("salary")); - salaryService.updateSalary(employeeId, newSalary); - return null; - }) - ); - } - -} diff --git a/samples/webmvc-http/src/main/java/io/spring/sample/graphql/greeting/GreetingController.java b/samples/webmvc-http/src/main/java/io/spring/sample/graphql/greeting/GreetingController.java new file mode 100644 index 00000000..05c5fc91 --- /dev/null +++ b/samples/webmvc-http/src/main/java/io/spring/sample/graphql/greeting/GreetingController.java @@ -0,0 +1,34 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.spring.sample.graphql.greeting; + +import org.springframework.graphql.data.method.annotation.GraphQlController; +import org.springframework.graphql.data.method.annotation.QueryMapping; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; + +import static org.springframework.web.context.request.RequestAttributes.SCOPE_REQUEST; + +@GraphQlController +public class GreetingController { + + @QueryMapping + public String greeting() { + RequestAttributes attributes = RequestContextHolder.getRequestAttributes(); + return "Hello " + attributes.getAttribute(RequestAttributeFilter.NAME_ATTRIBUTE, SCOPE_REQUEST); + } + +} diff --git a/samples/webmvc-http/src/main/java/io/spring/sample/graphql/greeting/GreetingDataWiring.java b/samples/webmvc-http/src/main/java/io/spring/sample/graphql/greeting/GreetingDataWiring.java deleted file mode 100644 index e68909d5..00000000 --- a/samples/webmvc-http/src/main/java/io/spring/sample/graphql/greeting/GreetingDataWiring.java +++ /dev/null @@ -1,23 +0,0 @@ -package io.spring.sample.graphql.greeting; - -import graphql.schema.idl.RuntimeWiring; - -import org.springframework.graphql.boot.RuntimeWiringBuilderCustomizer; -import org.springframework.stereotype.Component; -import org.springframework.web.context.request.RequestAttributes; -import org.springframework.web.context.request.RequestContextHolder; - -import static org.springframework.web.context.request.RequestAttributes.SCOPE_REQUEST; - -@Component -public class GreetingDataWiring implements RuntimeWiringBuilderCustomizer { - - @Override - public void customize(RuntimeWiring.Builder builder) { - builder.type("Query", typeWiring -> typeWiring.dataFetcher("greeting", env -> { - RequestAttributes attributes = RequestContextHolder.getRequestAttributes(); - return "Hello " + attributes.getAttribute(RequestAttributeFilter.NAME_ATTRIBUTE, SCOPE_REQUEST); - })); - } - -} diff --git a/samples/webmvc-http/src/main/java/io/spring/sample/graphql/project/ProjectController.java b/samples/webmvc-http/src/main/java/io/spring/sample/graphql/project/ProjectController.java new file mode 100644 index 00000000..96acbd1f --- /dev/null +++ b/samples/webmvc-http/src/main/java/io/spring/sample/graphql/project/ProjectController.java @@ -0,0 +1,44 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.spring.sample.graphql.project; + +import java.util.List; + +import org.springframework.graphql.data.method.annotation.Argument; +import org.springframework.graphql.data.method.annotation.SchemaMapping; +import org.springframework.graphql.data.method.annotation.GraphQlController; +import org.springframework.graphql.data.method.annotation.QueryMapping; + +@GraphQlController +public class ProjectController { + + private final SpringProjectsClient client; + + public ProjectController(SpringProjectsClient client) { + this.client = client; + } + + @QueryMapping + public Project project(@Argument String slug) { + return client.fetchProject(slug); + } + + @SchemaMapping + public List releases(Project project) { + return client.fetchProjectReleases(project.getSlug()); + } + +} diff --git a/samples/webmvc-http/src/main/java/io/spring/sample/graphql/project/ProjectDataWiring.java b/samples/webmvc-http/src/main/java/io/spring/sample/graphql/project/ProjectDataWiring.java deleted file mode 100644 index ec080418..00000000 --- a/samples/webmvc-http/src/main/java/io/spring/sample/graphql/project/ProjectDataWiring.java +++ /dev/null @@ -1,27 +0,0 @@ -package io.spring.sample.graphql.project; - -import graphql.schema.idl.RuntimeWiring; -import org.springframework.graphql.boot.RuntimeWiringBuilderCustomizer; -import org.springframework.stereotype.Component; - -@Component -public class ProjectDataWiring implements RuntimeWiringBuilderCustomizer { - - private final SpringProjectsClient client; - - public ProjectDataWiring(SpringProjectsClient client) { - this.client = client; - } - - @Override - public void customize(RuntimeWiring.Builder builder) { - builder.type("Query", typeWiring -> typeWiring.dataFetcher("project", env -> { - String slug = env.getArgument("slug"); - return client.fetchProject(slug); - })).type("Project", typeWiring -> typeWiring.dataFetcher("releases", env -> { - Project project = env.getSource(); - return client.fetchProjectReleases(project.getSlug()); - })); - } - -} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/AnnotatedDataFetcher.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/AnnotatedDataFetcher.java new file mode 100644 index 00000000..cf64905c --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/AnnotatedDataFetcher.java @@ -0,0 +1,71 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.graphql.data.method; + +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import graphql.schema.FieldCoordinates; + +/** + * {@link DataFetcher} that wrap and invokes a {@link HandlerMethod}. + * + * @author Rossen Stoyanchev + * @since 1.0.0 + */ +public class AnnotatedDataFetcher implements DataFetcher { + + private final FieldCoordinates coordinates; + + private final HandlerMethod handlerMethod; + + private final HandlerMethodArgumentResolverComposite argumentResolvers; + + + public AnnotatedDataFetcher(FieldCoordinates coordinates, HandlerMethod handlerMethod, + HandlerMethodArgumentResolverComposite resolvers) { + + this.coordinates = coordinates; + this.handlerMethod = handlerMethod; + this.argumentResolvers = resolvers; + } + + + /** + * Return the {@link FieldCoordinates} the HandlerMethod is mapped to. + */ + public FieldCoordinates getCoordinates() { + return this.coordinates; + } + + /** + * Return the {@link HandlerMethod} used to fetch data. + */ + public HandlerMethod getHandlerMethod() { + return this.handlerMethod; + } + + + @Override + @SuppressWarnings("ConstantConditions") + public Object get(DataFetchingEnvironment environment) throws Exception { + + InvocableHandlerMethod invocable = + new InvocableHandlerMethod(this.handlerMethod.createWithResolvedBean(), this.argumentResolvers); + + return invocable.invoke(environment); + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/AnnotatedDataFetcherRegistrar.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/AnnotatedDataFetcherRegistrar.java new file mode 100644 index 00000000..9107045b --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/AnnotatedDataFetcherRegistrar.java @@ -0,0 +1,334 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.graphql.data.method; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.stream.Collectors; + +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import graphql.schema.FieldCoordinates; +import graphql.schema.idl.RuntimeWiring; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.aop.support.AopUtils; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.core.MethodIntrospector; +import org.springframework.core.MethodParameter; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.ResolvableType; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.core.codec.Decoder; +import org.springframework.core.codec.Encoder; +import org.springframework.graphql.data.method.annotation.GraphQlController; +import org.springframework.graphql.data.method.annotation.SchemaMapping; +import org.springframework.graphql.data.method.annotation.MutationMapping; +import org.springframework.graphql.data.method.annotation.QueryMapping; +import org.springframework.graphql.data.method.annotation.SubscriptionMapping; +import org.springframework.graphql.data.method.annotation.support.InputArgumentMethodArgumentResolver; +import org.springframework.graphql.data.method.annotation.support.DataFetchingEnvironmentMethodArgumentResolver; +import org.springframework.graphql.data.method.annotation.support.SourceMethodArgumentResolver; +import org.springframework.http.MediaType; +import org.springframework.http.codec.DecoderHttpMessageReader; +import org.springframework.http.codec.EncoderHttpMessageWriter; +import org.springframework.http.codec.ServerCodecConfigurer; +import org.springframework.http.converter.GenericHttpMessageConverter; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; + +/** + * + * + * @author Rossen Stoyanchev + * @since 1.0.0 + */ +public class AnnotatedDataFetcherRegistrar implements ApplicationContextAware, InitializingBean { + + private final static Log logger = LogFactory.getLog(AnnotatedDataFetcherRegistrar.class); + + + /** + * Bean name prefix for target beans behind scoped proxies. Used to exclude those + * targets from handler method detection, in favor of the corresponding proxies. + *

We're not checking the autowire-candidate status here, which is how the + * proxy target filtering problem is being handled at the autowiring level, + * since autowire-candidate may have been turned to {@code false} for other + * reasons, while still expecting the bean to be eligible for handler methods. + *

Originally defined in {@link org.springframework.aop.scope.ScopedProxyUtils} + * but duplicated here to avoid a hard dependency on the spring-aop module. + */ + private static final String SCOPED_TARGET_NAME_PREFIX = "scopedTarget."; + + private static final ResolvableType MAP_RESOLVABLE_TYPE = + ResolvableType.forType(new ParameterizedTypeReference>() {}); + + + @Nullable + private ApplicationContext applicationContext; + + @Nullable + private HandlerMethodArgumentResolverComposite argumentResolvers; + + @Nullable + private GenericHttpMessageConverter jsonMessageConverter; + + @Nullable + private Encoder jsonEncoder; + + @Nullable + private Decoder jsonDecoder; + + + /** + * Configure the {@link org.springframework.http.converter.HttpMessageConverter} + * to use to convert input arguments obtained from the + * {@link DataFetchingEnvironment} and converted to the type of a declared + * {@link org.springframework.graphql.data.method.annotation.Argument @Argument} + * method parameter. + *

This method is mutually exclusive with + * {@link #setServerCodecConfigurer(ServerCodecConfigurer)} and is convenient + * for use in a Spring MVC application but both variant can be used without + * much difference. + * @param converter the converter to use. + */ + public void setJsonMessageConverter(@Nullable GenericHttpMessageConverter converter) { + this.jsonMessageConverter = converter; + } + + /** + * Variant of {@link #setJsonMessageConverter(GenericHttpMessageConverter)} + * to use an {@link Encoder} and {@link Decoder} to convert input arguments. + *

This method is mutually exclusive with + * {@link #setJsonMessageConverter(GenericHttpMessageConverter)} and is + * convenient for use in a Spring WebFlux application but both variant can + * be used without much difference. + */ + @SuppressWarnings("unchecked") + public void setServerCodecConfigurer(@Nullable ServerCodecConfigurer configurer) { + if (configurer == null) { + this.jsonDecoder = null; + this.jsonEncoder = null; + return; + } + this.jsonDecoder = configurer.getReaders().stream() + .filter((reader) -> reader.canRead(MAP_RESOLVABLE_TYPE, MediaType.APPLICATION_JSON)) + .map((reader) -> ((DecoderHttpMessageReader) reader).getDecoder()) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("No Decoder for JSON")); + this.jsonEncoder = configurer.getWriters().stream() + .filter((writer) -> writer.canWrite(MAP_RESOLVABLE_TYPE, MediaType.APPLICATION_JSON)) + .map((writer) -> ((EncoderHttpMessageWriter) writer).getEncoder()) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("No Encoder for JSON")); + } + + @Override + public void setApplicationContext(ApplicationContext applicationContext) { + this.applicationContext = applicationContext; + } + + + @Override + public void afterPropertiesSet() { + this.argumentResolvers = new HandlerMethodArgumentResolverComposite(); + this.argumentResolvers.addResolver(initInputArgumentMethodArgumentResolver()); + this.argumentResolvers.addResolver(new DataFetchingEnvironmentMethodArgumentResolver()); + this.argumentResolvers.addResolver(new SourceMethodArgumentResolver()); + } + + private InputArgumentMethodArgumentResolver initInputArgumentMethodArgumentResolver() { + InputArgumentMethodArgumentResolver argumentResolver; + if (this.jsonMessageConverter != null) { + argumentResolver = new InputArgumentMethodArgumentResolver(this.jsonMessageConverter); + } + else if (this.jsonEncoder != null && this.jsonDecoder != null) { + argumentResolver = new InputArgumentMethodArgumentResolver(this.jsonDecoder, this.jsonEncoder); + } + else { + throw new IllegalArgumentException( + "Neither HttpMessageConverter nor Encoder/Decoder for JSON provided"); + } + return argumentResolver; + } + + + public void register(RuntimeWiring.Builder builder) { + Assert.state(this.argumentResolvers != null, "`argumentResolvers` not initialized"); + Assert.state(this.applicationContext != null, "ApplicationContext is required"); + + detectHandlerMethods().forEach((coordinates, handlerMethod) -> { + DataFetcher dataFetcher = new AnnotatedDataFetcher(coordinates, handlerMethod, this.argumentResolvers); + builder.type(coordinates.getTypeName(), typeBuilder -> + typeBuilder.dataFetcher(coordinates.getFieldName(), dataFetcher)); + }); + } + + /** + * Scan beans in the ApplicationContext, detect and prepare a map of handler methods. + */ + private Map detectHandlerMethods() { + Map result = new HashMap<>(); + for (String beanName : this.applicationContext.getBeanNamesForType(Object.class)) { + if (beanName.startsWith(SCOPED_TARGET_NAME_PREFIX)) { + continue; + } + Class beanType = null; + try { + beanType = this.applicationContext.getType(beanName); + } + catch (Throwable ex) { + // An unresolvable bean type, probably from a lazy bean - let's ignore it. + if (logger.isTraceEnabled()) { + logger.trace("Could not resolve type for bean '" + beanName + "'", ex); + } + } + if (beanType == null || !isHandler(beanType)) { + continue; + } + detectHandlerMethodsOnBean(beanName).forEach((coordinates, handlerMethod) -> { + HandlerMethod existing = result.put(coordinates, handlerMethod); + if (existing != null && !existing.equals(handlerMethod)) { + throw new IllegalStateException( + "Ambiguous mapping. Cannot map '" + handlerMethod.getBean() + "' method \n" + + handlerMethod + "\nto " + coordinates + ": There is already '" + + existing.getBean() + "' bean method\n" + existing + " mapped."); + } + }); + } + return result; + } + + private boolean isHandler(Class beanType) { + return (AnnotatedElementUtils.hasAnnotation(beanType, GraphQlController.class) || + AnnotatedElementUtils.hasAnnotation(beanType, SchemaMapping.class)); + } + + private Map detectHandlerMethodsOnBean(Object handler) { + Class beanClass = (handler instanceof String ? + this.applicationContext.getType((String) handler) : handler.getClass()); + if (beanClass == null) { + return Collections.emptyMap(); + } + + Class userClass = ClassUtils.getUserClass(beanClass); + Map methodsMap = + MethodIntrospector.selectMethods(userClass, (Method method) -> getCoordinates(method, userClass)); + if (methodsMap.isEmpty()) { + return Collections.emptyMap(); + } + + Map result = new LinkedHashMap<>(methodsMap.size()); + for (Map.Entry entry : methodsMap.entrySet()) { + Method method = AopUtils.selectInvocableMethod(entry.getKey(), userClass); + HandlerMethod handlerMethod = (handler instanceof String ? + new HandlerMethod((String) handler, this.applicationContext.getAutowireCapableBeanFactory(), method) : + new HandlerMethod(handler, method)); + FieldCoordinates coordinates = entry.getValue(); + coordinates = updateCoordinates(coordinates, handlerMethod); + result.put(coordinates, handlerMethod); + } + + if (logger.isTraceEnabled()) { + logger.trace(formatMappings(userClass, result)); + } + + return result; + } + + @Nullable + private FieldCoordinates getCoordinates(Method method, Class handlerType) { + QueryMapping query = AnnotatedElementUtils.findMergedAnnotation(method, QueryMapping.class); + if (query != null) { + String name = (StringUtils.hasText(query.name()) ? query.name() : method.getName()); + return FieldCoordinates.coordinates("Query", name); + } + MutationMapping mutation = AnnotatedElementUtils.findMergedAnnotation(method, MutationMapping.class); + if (mutation != null) { + String name = (StringUtils.hasText(mutation.name()) ? mutation.name() : method.getName()); + return FieldCoordinates.coordinates("Mutation", name); + } + SubscriptionMapping subscription = AnnotatedElementUtils.findMergedAnnotation(method, SubscriptionMapping.class); + if (subscription != null) { + String name = (StringUtils.hasText(subscription.name()) ? subscription.name() : method.getName()); + return FieldCoordinates.coordinates("Subscription", name); + } + SchemaMapping schemaMapping = AnnotatedElementUtils.findMergedAnnotation(method, SchemaMapping.class); + if (schemaMapping != null) { + String typeName = schemaMapping.typeName(); + String field = schemaMapping.field(); + if (!StringUtils.hasText(typeName)) { + schemaMapping = AnnotatedElementUtils.findMergedAnnotation(handlerType, SchemaMapping.class); + if (schemaMapping != null) { + typeName = schemaMapping.typeName(); + } + } + return FieldCoordinates.coordinates(typeName, field); + } + return null; + } + + private FieldCoordinates updateCoordinates(FieldCoordinates coordinates, HandlerMethod handlerMethod) { + boolean hasTypeName = StringUtils.hasText(coordinates.getTypeName()); + boolean hasFieldName = StringUtils.hasText(coordinates.getFieldName()); + if (hasTypeName && hasFieldName) { + return coordinates; + } + + String typeName = coordinates.getTypeName(); + if (!hasTypeName) { + for (MethodParameter parameter : handlerMethod.getMethodParameters()) { + HandlerMethodArgumentResolver resolver = this.argumentResolvers.getArgumentResolver(parameter); + if (resolver instanceof SourceMethodArgumentResolver) { + typeName = parameter.getParameterType().getSimpleName(); + break; + } + } + Assert.hasText(typeName, + "No parentType specified, and a source/container method argument was also not found: " + + handlerMethod.getShortLogMessage()); + } + + return FieldCoordinates.coordinates(typeName, + (hasFieldName ? coordinates.getFieldName() : handlerMethod.getMethod().getName())); + } + + private String formatMappings(Class handlerType, Map mappings) { + String formattedType = Arrays.stream(ClassUtils.getPackageName(handlerType).split("\\.")) + .map(p -> p.substring(0, 1)) + .collect(Collectors.joining(".", "", "." + handlerType.getSimpleName())); + return mappings.entrySet().stream() + .map(entry -> { + Method method = entry.getValue().getMethod(); + String methodParameters = Arrays.stream(method.getParameterTypes()) + .map(Class::getSimpleName) + .collect(Collectors.joining(",", "(", ")")); + return entry.getKey() + " => " + method.getName() + methodParameters; + }) + .collect(Collectors.joining("\n\t", "\n\t" + formattedType + ":" + "\n\t", "")); + + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/HandlerMethod.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/HandlerMethod.java new file mode 100644 index 00000000..9f0f69e9 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/HandlerMethod.java @@ -0,0 +1,382 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.graphql.data.method; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.beans.factory.BeanFactory; +import org.springframework.core.BridgeMethodResolver; +import org.springframework.core.MethodParameter; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.core.annotation.SynthesizingMethodParameter; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; + +/** + * Encapsulates information about a handler method consisting of a + * {@linkplain #getMethod() method} and a {@linkplain #getBean() bean}. + * Provides convenient access to method parameters, the method return value, + * method annotations, etc. + * + *

The class may be created with a bean instance or with a bean name + * (e.g. lazy-init bean, prototype bean). Use {@link #createWithResolvedBean()} + * to obtain a {@code HandlerMethod} instance with a bean instance resolved + * through the associated {@link BeanFactory}. + * + * @author Rossen Stoyanchev + * @since 1.0.0 + */ +public class HandlerMethod { + + protected static final Log logger = LogFactory.getLog(HandlerMethod.class); + + + private final Object bean; + + @Nullable + private final BeanFactory beanFactory; + + private final Class beanType; + + private final Method method; + + private final Method bridgedMethod; + + private final MethodParameter[] parameters; + + + /** + * Constructor with a handler instance and a method. + */ + public HandlerMethod(Object bean, Method method) { + Assert.notNull(bean, "Bean is required"); + Assert.notNull(method, "Method is required"); + this.bean = bean; + this.beanFactory = null; + this.beanType = ClassUtils.getUserClass(bean); + this.method = method; + this.bridgedMethod = BridgeMethodResolver.findBridgedMethod(method); + this.parameters = initMethodParameters(); + } + + /** + * Constructor with a bean name for the handler along with a {@code BeanFactory} + * to allow {@link #createWithResolvedBean() resolving} the handler instance + * later. + */ + public HandlerMethod(String beanName, BeanFactory beanFactory, Method method) { + Assert.hasText(beanName, "Bean name is required"); + Assert.notNull(beanFactory, "BeanFactory is required"); + Assert.notNull(method, "Method is required"); + this.bean = beanName; + this.beanFactory = beanFactory; + Class beanType = beanFactory.getType(beanName); + if (beanType == null) { + throw new IllegalStateException( + "Cannot resolve bean type for bean with name '" + beanName + "'"); + } + this.beanType = ClassUtils.getUserClass(beanType); + this.method = method; + this.bridgedMethod = BridgeMethodResolver.findBridgedMethod(method); + this.parameters = initMethodParameters(); + } + + /** + * Copy constructor for use from subclasses that accept more arguments. + */ + protected HandlerMethod(HandlerMethod handlerMethod) { + this(handlerMethod, handlerMethod.bean); + } + + /** + * Re-create HandlerMethod with the resolved handler. + */ + private HandlerMethod(HandlerMethod handlerMethod, Object handler) { + Assert.notNull(handlerMethod, "HandlerMethod is required"); + Assert.notNull(handler, "Handler object is required"); + this.bean = handler; + this.beanFactory = handlerMethod.beanFactory; + this.beanType = handlerMethod.beanType; + this.method = handlerMethod.method; + this.bridgedMethod = handlerMethod.bridgedMethod; + this.parameters = handlerMethod.parameters; + } + + private MethodParameter[] initMethodParameters() { + int count = this.bridgedMethod.getParameterCount(); + MethodParameter[] result = new MethodParameter[count]; + for (int i = 0; i < count; i++) { + result[i] = new HandlerMethodParameter(i); + } + return result; + } + + + /** + * Return the bean for this handler method. + */ + public Object getBean() { + return this.bean; + } + + /** + * Return the method for this handler method. + */ + public Method getMethod() { + return this.method; + } + + /** + * This method returns the type of the handler for this handler method. + *

Note that if the bean type is a CGLIB-generated class, the original + * user-defined class is returned. + */ + public Class getBeanType() { + return this.beanType; + } + + /** + * If the bean method is a bridge method, this method returns the bridged + * (user-defined) method. Otherwise it returns the same method as {@link #getMethod()}. + */ + protected Method getBridgedMethod() { + return this.bridgedMethod; + } + + /** + * Return the method parameters for this handler method. + */ + public MethodParameter[] getMethodParameters() { + return this.parameters; + } + + /** + * Return the HandlerMethod return type. + */ + public MethodParameter getReturnType() { + return new HandlerMethodParameter(-1); + } + + /** + * Return the actual return value type. + */ + public MethodParameter getReturnValueType(@Nullable Object returnValue) { + return new ReturnValueMethodParameter(returnValue); + } + + /** + * Return {@code true} if the method return type is void, {@code false} otherwise. + */ + public boolean isVoid() { + return Void.TYPE.equals(getReturnType().getParameterType()); + } + + /** + * Return a single annotation on the underlying method traversing its super methods + * if no annotation can be found on the given method itself. + *

Also supports merged composed annotations with attribute + * overrides as of Spring Framework 4.3. + * @param annotationType the type of annotation to introspect the method for + * @return the annotation, or {@code null} if none found + * @see AnnotatedElementUtils#findMergedAnnotation + */ + @Nullable + public A getMethodAnnotation(Class annotationType) { + return AnnotatedElementUtils.findMergedAnnotation(this.method, annotationType); + } + + /** + * Return whether the parameter is declared with the given annotation type. + * @param annotationType the annotation type to look for + * @see AnnotatedElementUtils#hasAnnotation + */ + public boolean hasMethodAnnotation(Class annotationType) { + return AnnotatedElementUtils.hasAnnotation(this.method, annotationType); + } + + /** + * If the provided instance contains a bean name rather than an object instance, + * the bean name is resolved before a {@link HandlerMethod} is created and returned. + */ + public HandlerMethod createWithResolvedBean() { + Object handler = this.bean; + if (this.bean instanceof String) { + Assert.state(this.beanFactory != null, "Cannot resolve bean name without BeanFactory"); + String beanName = (String) this.bean; + handler = this.beanFactory.getBean(beanName); + } + return new HandlerMethod(this, handler); + } + + /** + * Return a short representation of this handler method for log message purposes. + */ + public String getShortLogMessage() { + int args = this.method.getParameterCount(); + return getBeanType().getSimpleName() + "#" + this.method.getName() + "[" + args + " args]"; + } + + + @Override + public boolean equals(@Nullable Object other) { + if (this == other) { + return true; + } + if (!(other instanceof HandlerMethod)) { + return false; + } + HandlerMethod otherMethod = (HandlerMethod) other; + return (this.bean.equals(otherMethod.bean) && this.method.equals(otherMethod.method)); + } + + @Override + public int hashCode() { + return (this.bean.hashCode() * 31 + this.method.hashCode()); + } + + @Override + public String toString() { + return this.method.toGenericString(); + } + + + // Support methods for use in "InvocableHandlerMethod" sub-class variants.. + + @Nullable + protected static Object findProvidedArgument(MethodParameter parameter, @Nullable Object... providedArgs) { + if (!ObjectUtils.isEmpty(providedArgs)) { + for (Object providedArg : providedArgs) { + if (parameter.getParameterType().isInstance(providedArg)) { + return providedArg; + } + } + } + return null; + } + + protected static String formatArgumentError(MethodParameter param, String message) { + return "Could not resolve parameter [" + param.getParameterIndex() + "] in " + + param.getExecutable().toGenericString() + (StringUtils.hasText(message) ? ": " + message : ""); + } + + /** + * Assert that the target bean class is an instance of the class where the given + * method is declared. In some cases the actual endpoint instance at request- + * processing time may be a JDK dynamic proxy (lazy initialization, prototype + * beans, and others). Endpoint classes that require proxying should prefer + * class-based proxy mechanisms. + */ + protected void assertTargetBean(Method method, Object targetBean, Object[] args) { + Class methodDeclaringClass = method.getDeclaringClass(); + Class targetBeanClass = targetBean.getClass(); + if (!methodDeclaringClass.isAssignableFrom(targetBeanClass)) { + String text = "The mapped handler method class '" + methodDeclaringClass.getName() + + "' is not an instance of the actual endpoint bean class '" + + targetBeanClass.getName() + "'. If the class requires proxying " + + "(e.g. due to @Transactional), please use class-based proxying."; + throw new IllegalStateException(formatInvokeError(text, args)); + } + } + + protected String formatInvokeError(String text, Object[] args) { + + String formattedArgs = IntStream.range(0, args.length) + .mapToObj(i -> (args[i] != null ? + "[" + i + "] [type=" + args[i].getClass().getName() + "] [value=" + args[i] + "]" : + "[" + i + "] [null]")) + .collect(Collectors.joining(",\n", " ", " ")); + + return text + "\n" + + "Class [" + getBeanType().getName() + "]\n" + + "Method [" + getBridgedMethod().toGenericString() + "] " + + "with argument values:\n" + formattedArgs; + } + + + /** + * A MethodParameter with HandlerMethod-specific behavior. + */ + protected class HandlerMethodParameter extends SynthesizingMethodParameter { + + public HandlerMethodParameter(int index) { + super(HandlerMethod.this.bridgedMethod, index); + } + + protected HandlerMethodParameter(HandlerMethodParameter original) { + super(original); + } + + @Override + public Class getContainingClass() { + return HandlerMethod.this.getBeanType(); + } + + @Override + public T getMethodAnnotation(Class annotationType) { + return HandlerMethod.this.getMethodAnnotation(annotationType); + } + + @Override + public boolean hasMethodAnnotation(Class annotationType) { + return HandlerMethod.this.hasMethodAnnotation(annotationType); + } + + @Override + public HandlerMethodParameter clone() { + return new HandlerMethodParameter(this); + } + } + + + /** + * A MethodParameter for a HandlerMethod return type based on an actual return value. + */ + private class ReturnValueMethodParameter extends HandlerMethodParameter { + + @Nullable + private final Object returnValue; + + public ReturnValueMethodParameter(@Nullable Object returnValue) { + super(-1); + this.returnValue = returnValue; + } + + protected ReturnValueMethodParameter(ReturnValueMethodParameter original) { + super(original); + this.returnValue = original.returnValue; + } + + @Override + public Class getParameterType() { + return (this.returnValue != null ? this.returnValue.getClass() : super.getParameterType()); + } + + @Override + public ReturnValueMethodParameter clone() { + return new ReturnValueMethodParameter(this); + } + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/HandlerMethodArgumentResolver.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/HandlerMethodArgumentResolver.java new file mode 100644 index 00000000..4f350e72 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/HandlerMethodArgumentResolver.java @@ -0,0 +1,60 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.graphql.data.method; + +import graphql.schema.DataFetchingEnvironment; + +import org.springframework.core.MethodParameter; +import org.springframework.lang.Nullable; +import org.springframework.web.bind.WebDataBinder; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.method.support.ModelAndViewContainer; + +/** + * Strategy interface for resolving method parameters into argument values in + * the context of a given request. + * + * @author Rossen Stoyanchev + * @since 1.0.0 + */ +public interface HandlerMethodArgumentResolver { + + /** + * Whether the given {@linkplain MethodParameter method parameter} is + * supported by this resolver. + * @param parameter the method parameter to check + * @return {@code true} if this resolver supports the supplied parameter; + * {@code false} otherwise + */ + boolean supportsParameter(MethodParameter parameter); + + /** + * Resolves a method parameter into an argument value from a given request. + * A {@link ModelAndViewContainer} provides access to the model for the + * request. A {@link WebDataBinderFactory} provides a way to create + * a {@link WebDataBinder} instance when needed for data binding and + * type conversion purposes. + * @param parameter the method parameter to resolve. This parameter must + * have previously been passed to {@link #supportsParameter} which must + * have returned {@code true}. + * @param environment the GraphQL {@link DataFetchingEnvironment} + * @return the resolved argument value, or {@code null} if not resolvable + * @throws Exception in case of errors with the preparation of argument values + */ + @Nullable + Object resolveArgument(MethodParameter parameter, DataFetchingEnvironment environment) throws Exception; + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/HandlerMethodArgumentResolverComposite.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/HandlerMethodArgumentResolverComposite.java new file mode 100644 index 00000000..b6b7c7f5 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/HandlerMethodArgumentResolverComposite.java @@ -0,0 +1,105 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.graphql.data.method; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import graphql.schema.DataFetchingEnvironment; + +import org.springframework.core.MethodParameter; +import org.springframework.lang.Nullable; + +/** + * Resolves method parameters by delegating to a list of registered + * {@link HandlerMethodArgumentResolver HandlerMethodArgumentResolver}'s. + * Previously resolved method parameters are cached for faster lookups. + * + * @author Rossen Stoyanchev + * @since 1.0.0 + */ +public class HandlerMethodArgumentResolverComposite implements HandlerMethodArgumentResolver { + + private final List argumentResolvers = new ArrayList<>(); + + private final Map argumentResolverCache = + new ConcurrentHashMap<>(256); + + + /** + * Add the given {@link HandlerMethodArgumentResolver}. + */ + public void addResolver(HandlerMethodArgumentResolver resolver) { + this.argumentResolvers.add(resolver); + } + + /** + * Return a read-only list with the contained resolvers, or an empty list. + */ + public List getResolvers() { + return Collections.unmodifiableList(this.argumentResolvers); + } + + + /** + * Whether the given {@linkplain MethodParameter method parameter} is + * supported by any registered {@link HandlerMethodArgumentResolver}. + */ + @Override + public boolean supportsParameter(MethodParameter parameter) { + return getArgumentResolver(parameter) != null; + } + + /** + * Iterate over registered + * {@link HandlerMethodArgumentResolver HandlerMethodArgumentResolvers} + * and invoke the one that supports it. + * @throws IllegalArgumentException if no suitable argument resolver is found + */ + @Override + @Nullable + public Object resolveArgument(MethodParameter parameter, DataFetchingEnvironment environment) throws Exception { + HandlerMethodArgumentResolver resolver = getArgumentResolver(parameter); + if (resolver == null) { + throw new IllegalArgumentException("Unsupported parameter type [" + + parameter.getParameterType().getName() + "]. supportsParameter should be called first."); + } + return resolver.resolveArgument(parameter, environment); + } + + /** + * Find a registered {@link HandlerMethodArgumentResolver} that supports + * the given method parameter. + */ + @Nullable + public HandlerMethodArgumentResolver getArgumentResolver(MethodParameter parameter) { + HandlerMethodArgumentResolver result = this.argumentResolverCache.get(parameter); + if (result == null) { + for (HandlerMethodArgumentResolver resolver : this.argumentResolvers) { + if (resolver.supportsParameter(parameter)) { + result = resolver; + this.argumentResolverCache.put(parameter, result); + break; + } + } + } + return result; + } + +} \ No newline at end of file diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/InvocableHandlerMethod.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/InvocableHandlerMethod.java new file mode 100644 index 00000000..a17b08f3 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/InvocableHandlerMethod.java @@ -0,0 +1,166 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.graphql.data.method; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Arrays; + +import graphql.schema.DataFetchingEnvironment; + +import org.springframework.core.CoroutinesUtils; +import org.springframework.core.DefaultParameterNameDiscoverer; +import org.springframework.core.KotlinDetector; +import org.springframework.core.MethodParameter; +import org.springframework.core.ParameterNameDiscoverer; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; +import org.springframework.util.ReflectionUtils; + +/** + * Extension of {@link HandlerMethod} that can resolve method arguments from a + * {@link DataFetchingEnvironment} and invoke the method. + * + * @author Rossen Stoyanchev + * @since 1.0.0 + */ +public class InvocableHandlerMethod extends HandlerMethod { + + private static final Object[] EMPTY_ARGS = new Object[0]; + + + private final HandlerMethodArgumentResolverComposite resolvers; + + private final ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer(); + + + public InvocableHandlerMethod(HandlerMethod handlerMethod, HandlerMethodArgumentResolverComposite resolvers) { + super(handlerMethod); + Assert.isTrue(!resolvers.getResolvers().isEmpty(), "No argument resolvers"); + this.resolvers = resolvers; + } + + + /** + * Return the configured argument resolvers. + */ + public HandlerMethodArgumentResolverComposite getResolvers() { + return this.resolvers; + } + + + /** + * Invoke the method after resolving its argument values in the context of + * the given environment. + *

Argument values are commonly resolved through + * {@link HandlerMethodArgumentResolver HandlerMethodArgumentResolvers}. + * The {@code providedArgs} parameter however may supply argument values to + * be used directly, i.e. without argument resolution. Provided argument + * values are checked before argument resolvers. + * @param environment the GraphQL {@link DataFetchingEnvironment} + * @return the raw value returned by the invoked method + * @throws Exception raised if no suitable argument resolver can be found, + * or if the method raised an exception + * @see #getMethodArgumentValues + * @see #doInvoke + */ + @Nullable + public Object invoke(DataFetchingEnvironment environment) throws Exception { + Object[] args = getMethodArgumentValues(environment); + if (logger.isTraceEnabled()) { + logger.trace("Arguments: " + Arrays.toString(args)); + } + return doInvoke(args); + } + + /** + * Get the method argument values for the current request, checking the provided + * argument values and falling back to the configured argument resolvers. + *

The resulting array will be passed into {@link #doInvoke}. + */ + protected Object[] getMethodArgumentValues( + DataFetchingEnvironment environment, Object... providedArgs) throws Exception { + + MethodParameter[] parameters = getMethodParameters(); + if (ObjectUtils.isEmpty(parameters)) { + return EMPTY_ARGS; + } + Object[] args = new Object[parameters.length]; + for (int i = 0; i < parameters.length; i++) { + MethodParameter parameter = parameters[i]; + parameter.initParameterNameDiscovery(this.parameterNameDiscoverer); + args[i] = findProvidedArgument(parameter, providedArgs); + if (args[i] != null) { + continue; + } + if (!this.resolvers.supportsParameter(parameter)) { + throw new IllegalStateException(formatArgumentError(parameter, "No suitable resolver")); + } + try { + args[i] = this.resolvers.resolveArgument(parameter, environment); + } + catch (Exception ex) { + // Leave stack trace for later, exception may actually be resolved and handled... + if (logger.isDebugEnabled()) { + String exMsg = ex.getMessage(); + if (exMsg != null && !exMsg.contains(parameter.getExecutable().toGenericString())) { + logger.debug(formatArgumentError(parameter, exMsg)); + } + } + throw ex; + } + } + return args; + } + + /** + * Invoke the handler method with the given argument values. + */ + @Nullable + protected Object doInvoke(Object... args) throws Exception { + Method method = getBridgedMethod(); + ReflectionUtils.makeAccessible(method); + try { + if (KotlinDetector.isSuspendingFunction(method)) { + return CoroutinesUtils.invokeSuspendingFunction(method, getBean(), args); + } + return method.invoke(getBean(), args); + } + catch (IllegalArgumentException ex) { + assertTargetBean(method, getBean(), args); + String text = (ex.getMessage() != null ? ex.getMessage() : "Illegal argument"); + throw new IllegalStateException(formatInvokeError(text, args), ex); + } + catch (InvocationTargetException ex) { + // Unwrap for DataFetcherExceptionResolvers ... + Throwable targetException = ex.getTargetException(); + if (targetException instanceof RuntimeException) { + throw (RuntimeException) targetException; + } + else if (targetException instanceof Error) { + throw (Error) targetException; + } + else if (targetException instanceof Exception) { + throw (Exception) targetException; + } + else { + throw new IllegalStateException(formatInvokeError("Invocation failure", args), targetException); + } + } + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/Argument.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/Argument.java new file mode 100644 index 00000000..4e781f9c --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/Argument.java @@ -0,0 +1,73 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.graphql.data.method.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + + +import org.springframework.core.annotation.AliasFor; + +/** + * Annotation to bind a method parameter to a GraphQL input + * {@link graphql.schema.DataFetchingEnvironment#getArgument(String) argument}. + * + *

If the method parameter is {@link java.util.Map Map<String, Object>} or + * and a parameter name is not specified, then the map parameter is populated + * via {@link graphql.schema.DataFetchingEnvironment#getArguments()}. + * + * @author Rossen Stoyanchev + * @since 1.0.0 + */ +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface Argument { + + /** + * Alias for {@link #name}. + */ + @AliasFor("name") + String value() default ""; + + /** + * The name of the input argument to bind to. + */ + @AliasFor("value") + String name() default ""; + + /** + * Whether the input argument is required. + *

Defaults to {@code true}, leading to an exception being thrown + * if the argument is missing. Switch this to {@code false} if you prefer + * a {@code null} value when the parameter is not present. + *

Alternatively, provide a {@link #defaultValue}, which implicitly + * sets this flag to {@code false}. + */ + boolean required() default true; + + /** + * The default value to use as a fallback when an input argument is + * not present or has an empty value. + *

Supplying a default value implicitly sets {@link #required} to + * {@code false}. + */ + String defaultValue() default ValueConstants.DEFAULT_NONE; + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/GraphQlController.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/GraphQlController.java new file mode 100644 index 00000000..ba4af458 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/GraphQlController.java @@ -0,0 +1,40 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.graphql.data.method.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.stereotype.Controller; + +/** + * Indicates the annotated class is a "Controller" in a GraphQL application and + * exposes handler methods that fetch data, typically annotated with + * {@link SchemaMapping} annotations, or one of its shortcut annotations + * {@link QueryMapping}, {@link MutationMapping}, and {@link SubscriptionMapping}. + * + * @author Rossen Stoyanchev + * @since 1.0.0 + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Controller +public @interface GraphQlController { +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/MutationMapping.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/MutationMapping.java new file mode 100644 index 00000000..f6882220 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/MutationMapping.java @@ -0,0 +1,53 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.graphql.data.method.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.core.annotation.AliasFor; + +/** + * Annotation to express a mapping to a GraphQL Mutation operation. + * + *

Specifically, {@code @QueryMapping} is a composed annotation that + * acts as a shortcut for {@code @SchemaMapping(typeName = "Mutation")}. + * + * @author Rossen Stoyanchev + * @since 1.0.0 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@SchemaMapping(typeName = "Mutation") +public @interface MutationMapping { + + /** + * Alias for {@link SchemaMapping#field()}. + */ + @AliasFor(annotation = SchemaMapping.class, attribute = "field") + String name() default ""; + + /** + * Alias for {@link SchemaMapping#field()}. + */ + @AliasFor(annotation = SchemaMapping.class, attribute = "field") + String value() default ""; + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/QueryMapping.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/QueryMapping.java new file mode 100644 index 00000000..f5630ede --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/QueryMapping.java @@ -0,0 +1,53 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.graphql.data.method.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.core.annotation.AliasFor; + +/** + * Annotation to express a mapping to a GraphQL Query operation. + * + *

Specifically, {@code @QueryMapping} is a composed annotation that + * acts as a shortcut for {@code @SchemaMapping(typeName = "Query")}. + * + * @author Rossen Stoyanchev + * @since 1.0.0 + */ +@Target(value = {ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@SchemaMapping(typeName = "Query") +public @interface QueryMapping { + + /** + * Alias for {@link SchemaMapping#field()}. + */ + @AliasFor(annotation = SchemaMapping.class, attribute = "field") + String name() default ""; + + /** + * Alias for {@link SchemaMapping#field()}. + */ + @AliasFor(annotation = SchemaMapping.class, attribute = "field") + String value() default ""; + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/SchemaMapping.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/SchemaMapping.java new file mode 100644 index 00000000..c086111f --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/SchemaMapping.java @@ -0,0 +1,65 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.graphql.data.method.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import graphql.schema.DataFetchingEnvironment; + +import org.springframework.core.annotation.AliasFor; + +/** + * Annotation to express a mapping to a GraphQL type and field pair. Typically + * used on methods, but also possible to use at the class level to specify a + * default type name for all handler methods. + * + * @author Rossen Stoyanchev + * @since 1.0.0 + */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface SchemaMapping { + + /** + * Customize the name of the GraphQL field to bind to. + *

By default, if not specified, this is initialized from the method name. + */ + @AliasFor("value") + String field() default ""; + + /** + * Effectively an alias for {@link #field()}. + */ + @AliasFor("field") + String value() default ""; + + /** + * Customizes the name of the parent/container type for the GraphQL field. + *

By default, if not specified, it is derived from the class name of a + * {@link DataFetchingEnvironment#getSource() source} argument injected into + * the handler method. + *

This attributed is supported at the class level and at the method level! + * When used on both levels, the one on the method level overrides the one + * at the class level. + */ + String typeName() default ""; + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/SubscriptionMapping.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/SubscriptionMapping.java new file mode 100644 index 00000000..46dbf05e --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/SubscriptionMapping.java @@ -0,0 +1,53 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.graphql.data.method.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.core.annotation.AliasFor; + +/** + * Annotation to express a mapping to a GraphQL Subscription operation. + * + *

Specifically, {@code @QueryMapping} is a composed annotation that + * acts as a shortcut for {@code @SchemaMapping(typeName = "Subscription")}. + * + * @author Rossen Stoyanchev + * @since 1.0.0 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@SchemaMapping(typeName = "Subscription") +public @interface SubscriptionMapping { + + /** + * Alias for {@link SchemaMapping#field()}. + */ + @AliasFor(annotation = SchemaMapping.class, attribute = "field") + String name() default ""; + + /** + * Alias for {@link SchemaMapping#field()}. + */ + @AliasFor(annotation = SchemaMapping.class, attribute = "field") + String value() default ""; + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/ValueConstants.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/ValueConstants.java new file mode 100644 index 00000000..60801915 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/ValueConstants.java @@ -0,0 +1,36 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.graphql.data.method.annotation; + +/** + * Common annotation value constants. + * + * @author Rossen Stoyanchev + * @since 1.0.0 + */ +public interface ValueConstants { + + /** + * Constant defining a value for no default - as a replacement for {@code null} which + * we cannot use in annotation attributes. + *

This is an artificial, fixed value of 16 unicode characters, with its sole purpose + * being to never match a user-declared value. + * @see Argument#defaultValue() + */ + String DEFAULT_NONE = "\n\t\t\n\t\t\n\uE000\uE001\uE002\n\t\t\t\t\n"; + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/package-info.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/package-info.java new file mode 100644 index 00000000..f2145028 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/package-info.java @@ -0,0 +1,26 @@ +/* + * Copyright 2020-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Annotations for binding data fetching methods to GraphQL schema queries, + * mutations, subscriptions, and fields. + */ +@NonNullApi +@NonNullFields +package org.springframework.graphql.data.method.annotation; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/DataFetchingEnvironmentMethodArgumentResolver.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/DataFetchingEnvironmentMethodArgumentResolver.java new file mode 100644 index 00000000..28d91d2a --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/DataFetchingEnvironmentMethodArgumentResolver.java @@ -0,0 +1,41 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.graphql.data.method.annotation.support; + +import graphql.schema.DataFetchingEnvironment; + +import org.springframework.core.MethodParameter; +import org.springframework.graphql.data.method.HandlerMethodArgumentResolver; + +/** + * Resolver for a {@link DataFetchingEnvironment} argument. + * + * @author Rossen Stoyanchev + * @since 1.0.0 + */ +public class DataFetchingEnvironmentMethodArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterType().equals(DataFetchingEnvironment.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, DataFetchingEnvironment environment) { + return environment; + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/InputArgumentMethodArgumentResolver.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/InputArgumentMethodArgumentResolver.java new file mode 100644 index 00000000..17912de9 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/InputArgumentMethodArgumentResolver.java @@ -0,0 +1,234 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.graphql.data.method.annotation.support; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import graphql.schema.DataFetchingEnvironment; + +import org.springframework.core.MethodParameter; +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.Decoder; +import org.springframework.core.codec.Encoder; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.graphql.data.method.HandlerMethodArgumentResolver; +import org.springframework.graphql.data.method.annotation.Argument; +import org.springframework.graphql.data.method.annotation.ValueConstants; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpInputMessage; +import org.springframework.http.HttpOutputMessage; +import org.springframework.http.MediaType; +import org.springframework.http.converter.GenericHttpMessageConverter; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.MimeTypeUtils; +import org.springframework.util.StringUtils; + +/** + * Resolver for {@link Argument @Argument} annotated method parameters, obtained + * via {@link DataFetchingEnvironment#getArgument(String)} and converted to the + * declared type of the method parameter. + * + * @author Rossen Stoyanchev + * @since 1.0.0 + */ +public class InputArgumentMethodArgumentResolver implements HandlerMethodArgumentResolver { + + private final ArgumentConverter argumentConverter; + + /** + * Constructor with an + * {@link org.springframework.http.converter.HttpMessageConverter} to convert + * Map-based input arguments to higher level Objects. + */ + public InputArgumentMethodArgumentResolver(GenericHttpMessageConverter converter) { + this.argumentConverter = new MessageConverterArgumentConverter(converter); + } + + /** + * Variant of + * {@link #InputArgumentMethodArgumentResolver(GenericHttpMessageConverter)} + * to use an {@link Encoder} and {@link Decoder} to convert input arguments. + */ + public InputArgumentMethodArgumentResolver(Decoder decoder, Encoder encoder) { + this.argumentConverter = new CodecArgumentConverter(decoder, encoder); + } + + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterAnnotation(Argument.class) != null; + } + + @Override + public Object resolveArgument(MethodParameter parameter, DataFetchingEnvironment environment) throws Exception { + Argument annotation = parameter.getParameterAnnotation(Argument.class); + Assert.notNull(annotation, "No @Argument annotation"); + String name = annotation.name(); + if (!StringUtils.hasText(name)) { + name = parameter.getParameterName(); + if (name == null) { + throw new IllegalArgumentException( + "Name for argument of type [" + parameter.getNestedParameterType().getName() + + "] not specified, and parameter name information not found in class file either."); + } + } + + Object rawValue = (ValueConstants.DEFAULT_NONE.equals(annotation.defaultValue()) ? + environment.getArgument(name) : + environment.getArgumentOrDefault(name, annotation.defaultValue())); + + Class parameterType = parameter.getParameterType(); + + if (rawValue == null) { + if (annotation.required()) { + throw new MissingArgumentException(name, parameter); + } + if (parameterType.equals(Optional.class)) { + return Optional.empty(); + } + return null; + } + + if (parameterType.isAssignableFrom(rawValue.getClass())) { + return returnValue(rawValue, parameterType); + } + + if (rawValue instanceof List) { + Assert.isAssignable(List.class, parameterType, + "Argument '" + name + "' is a List while the @Argument method parameter is " + parameterType); + List valueList = (List) rawValue; + Class elementType = parameter.nestedIfOptional().getNestedParameterType(); + if (valueList.isEmpty() || elementType.isAssignableFrom(valueList.get(0).getClass())) { + return returnValue(rawValue, parameterType); + } + } + + Object decodedValue = this.argumentConverter.convert(rawValue, parameter); + Assert.notNull(decodedValue, "Argument '" + name + "' with raw value '" + rawValue + "'was decoded to null"); + return returnValue(decodedValue, parameterType); + } + + private Object returnValue(Object value, Class parameterType) { + return (parameterType.equals(Optional.class) ? Optional.of(value) : value); + } + + + /** + * Contract to abstract use of an HttpMessageConverter vs Encoder/Decoder. + */ + private interface ArgumentConverter { + + @Nullable + Object convert(Object rawValue, MethodParameter targetParameter) throws Exception; + + } + + + /** + * HttpMessageConverter based implementation of ArgumentConverter. + */ + private static class MessageConverterArgumentConverter implements ArgumentConverter { + + private final GenericHttpMessageConverter converter; + + public MessageConverterArgumentConverter(GenericHttpMessageConverter converter) { + this.converter = converter; + } + + @Override + public Object convert(Object rawValue, MethodParameter targetParameter) throws IOException { + HttpOutputMessageAdapter outMessage = new HttpOutputMessageAdapter(); + this.converter.write(rawValue, MediaType.APPLICATION_JSON, outMessage); + + HttpInputMessageAdapter inMessage = new HttpInputMessageAdapter(outMessage); + return this.converter.read(targetParameter.getGenericParameterType(), rawValue.getClass(), inMessage); + } + } + + + /** + * Encoder/Decoder based implementation of ArgumentConverter. + */ + private static class CodecArgumentConverter implements ArgumentConverter { + + private final Decoder decoder; + + private final Encoder encoder; + + public CodecArgumentConverter(Decoder decoder, Encoder encoder) { + Assert.notNull(decoder, "Decoder is required"); + Assert.notNull(encoder, "Encoder is required"); + this.decoder = decoder; + this.encoder = encoder; + } + + @Override + public Object convert(Object rawValue, MethodParameter targetParameter) { + DataBuffer dataBuffer = this.encoder.encodeValue( + rawValue, DefaultDataBufferFactory.sharedInstance, ResolvableType.forInstance(rawValue), + MimeTypeUtils.APPLICATION_JSON, Collections.emptyMap()); + + return this.decoder.decode( + dataBuffer, ResolvableType.forMethodParameter(targetParameter.nestedIfOptional()), + MimeTypeUtils.APPLICATION_JSON, Collections.emptyMap()); + } + } + + + private static class HttpInputMessageAdapter extends ByteArrayInputStream implements HttpInputMessage { + + HttpInputMessageAdapter(HttpOutputMessageAdapter messageAdapter) { + super(messageAdapter.toByteArray()); + } + + @Override + public InputStream getBody() { + return this; + } + + @Override + public HttpHeaders getHeaders() { + return HttpHeaders.EMPTY; + } + + } + + private static class HttpOutputMessageAdapter extends ByteArrayOutputStream implements HttpOutputMessage { + + private static final HttpHeaders noOpHeaders = new HttpHeaders(); + + @Override + public OutputStream getBody() { + return this; + } + + @Override + public HttpHeaders getHeaders() { + return noOpHeaders; + } + + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/MissingArgumentException.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/MissingArgumentException.java new file mode 100644 index 00000000..75957d3c --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/MissingArgumentException.java @@ -0,0 +1,62 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.graphql.data.method.annotation.support; + +import org.springframework.core.MethodParameter; +import org.springframework.core.NestedRuntimeException; + +/** + * Indicates that an input argument value in the method parameters of an + * annotated DataFetcher method is not present. + * + * @author Rossen Stoyanchev + * @since 1.0.0 + */ +public class MissingArgumentException extends NestedRuntimeException { + + private final String argumentName; + + private final MethodParameter parameter; + + + public MissingArgumentException(String argumentName, MethodParameter parameter) { + super(""); + this.argumentName = argumentName; + this.parameter = parameter; + } + + + /** + * Return the expected name of the input argument. + */ + public String getArgumentName() { + return this.argumentName; + } + + /** + * Return the method parameter bound to the input argument. + */ + public MethodParameter getParameter() { + return this.parameter; + } + + @Override + public String getMessage() { + return "Required argument '" + this.argumentName +"' for method parameter type " + + this.parameter.getNestedParameterType().getSimpleName() + " is not present"; + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/SourceMethodArgumentResolver.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/SourceMethodArgumentResolver.java new file mode 100644 index 00000000..d154995b --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/SourceMethodArgumentResolver.java @@ -0,0 +1,55 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.graphql.data.method.annotation.support; + +import java.util.Collection; + +import graphql.schema.DataFetchingEnvironment; + +import org.springframework.beans.BeanUtils; +import org.springframework.core.MethodParameter; +import org.springframework.graphql.data.method.HandlerMethodArgumentResolver; +import org.springframework.util.Assert; + +/** + * Resolver for parent/container of a field, obtained via + * {@link DataFetchingEnvironment#getSource()}. + * + *

This resolver supports any non-simple value type, also excluding arrays + * and collections, and therefore must be ordered last, in a fallback mode, + * allowing other resolvers to resolve the argument first. + * + * @author Rossen Stoyanchev + * @since 1.0.0 + */ +public class SourceMethodArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + Class type = parameter.getParameterType(); + return (!BeanUtils.isSimpleValueType(type) && !type.isArray() && !Collection.class.isAssignableFrom(type)); + } + + @Override + public Object resolveArgument(MethodParameter parameter, DataFetchingEnvironment environment) { + Object source = environment.getSource(); + Assert.isInstanceOf(parameter.getParameterType(), source, + "The declared parameter of type '" + parameter.getParameterType() + "' " + + "does not match the type of the source Object '" + source.getClass() + "'."); + return source; + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/package-info.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/package-info.java new file mode 100644 index 00000000..9c464ed1 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/package-info.java @@ -0,0 +1,10 @@ +/** + * Resolvers for method parameters of annotated handler methods. + */ + +@NonNullApi +@NonNullFields +package org.springframework.graphql.data.method.annotation.support; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/package-info.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/package-info.java new file mode 100644 index 00000000..28b8c0b6 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/package-info.java @@ -0,0 +1,10 @@ +/** + * Support for DataFetcher's based on methods in a handler class. + */ + +@NonNullApi +@NonNullFields +package org.springframework.graphql.data.method; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/spring-graphql/src/test/java/org/springframework/graphql/Author.java b/spring-graphql/src/test/java/org/springframework/graphql/Author.java index 97b3b642..21b0745b 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/Author.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/Author.java @@ -17,6 +17,8 @@ package org.springframework.graphql; public class Author { + Long id; + String firstName; String lastName; @@ -24,12 +26,21 @@ public class Author { public Author() { } - public Author(String firstName, String lastName) { + public Author(Long id, String firstName, String lastName) { + this.id = id; this.firstName = firstName; this.lastName = lastName; } + public void setId(Long id) { + this.id = id; + } + + public Long getId() { + return this.id; + } + public String getFirstName() { return this.firstName; } diff --git a/spring-graphql/src/test/java/org/springframework/graphql/BookCriteria.java b/spring-graphql/src/test/java/org/springframework/graphql/BookCriteria.java new file mode 100644 index 00000000..2390bb8a --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/BookCriteria.java @@ -0,0 +1,41 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.graphql; + +public class BookCriteria { + + private Long id; + + private String author; + + + public Long getId() { + return this.id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getAuthor() { + return this.author; + } + + public void setAuthor(String author) { + this.author = author; + } + +} diff --git a/spring-graphql/src/test/java/org/springframework/graphql/BookSource.java b/spring-graphql/src/test/java/org/springframework/graphql/BookSource.java index 490620e2..271d4513 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/BookSource.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/BookSource.java @@ -20,18 +20,29 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import reactor.core.publisher.Flux; + public class BookSource { - private static final Map booksMap = new HashMap<>(4); + private static final Map booksMap = new HashMap<>(); + + private static final Map authorsMap = new HashMap<>(); static { - booksMap.put(1L, new Book(1L, "Nineteen Eighty-Four", new Author("George", "Orwell"))); - booksMap.put(2L, new Book(2L, "The Great Gatsby", new Author("F. Scott", "Fitzgerald"))); - booksMap.put(3L, new Book(3L, "Catch-22", new Author("Joseph", "Heller"))); - booksMap.put(4L, new Book(4L, "To The Lighthouse", new Author("Virginia", "Woolf"))); - booksMap.put(5L, new Book(5L, "Animal Farm", new Author("George", "Orwell"))); - booksMap.put(42L, new Book(42L, "Hitchhiker's Guide to the Galaxy", new Author("Douglas", "Adams"))); - booksMap.put(53L, new Book(53L, "Breaking Bad", new Author("Vince", "Gilligan"))); + authorsMap.put(1L, new Author(1L, "George", "Orwell")); + authorsMap.put(2L, new Author(2L, "F. Scott", "Fitzgerald")); + authorsMap.put(3L, new Author(3L, "Joseph", "Heller")); + authorsMap.put(4L, new Author(4L, "Virginia", "Woolf")); + authorsMap.put(5L, new Author(5L, "Douglas", "Adams")); + authorsMap.put(6L, new Author(6L, "Vince", "Gilligan")); + + booksMap.put(1L, new Book(1L, "Nineteen Eighty-Four", authorsMap.get(1L))); + booksMap.put(2L, new Book(2L, "The Great Gatsby", authorsMap.get(2L))); + booksMap.put(3L, new Book(3L, "Catch-22", authorsMap.get(3L))); + booksMap.put(4L, new Book(4L, "To The Lighthouse", authorsMap.get(4L))); + booksMap.put(5L, new Book(5L, "Animal Farm", authorsMap.get(1L))); + booksMap.put(42L, new Book(42L, "Hitchhiker's Guide to the Galaxy", authorsMap.get(5L))); + booksMap.put(53L, new Book(53L, "Breaking Bad", authorsMap.get(6L))); } @@ -47,4 +58,16 @@ public class BookSource { return booksMap.get(id); } + @SuppressWarnings("ConstantConditions") + public static List findBooksByAuthor(String author) { + return Flux.fromIterable(books()) + .filter((book) -> book.getAuthor().getFullName().contains(author)) + .collectList() + .block(); + } + + public static Author getAuthor(Long id) { + return authorsMap.get(id); + } + } diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/method/AnnotatedDataFetcherInvocationTests.java b/spring-graphql/src/test/java/org/springframework/graphql/data/method/AnnotatedDataFetcherInvocationTests.java new file mode 100644 index 00000000..568978a0 --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/method/AnnotatedDataFetcherInvocationTests.java @@ -0,0 +1,237 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.graphql.data.method; + +import java.util.List; +import java.util.Map; + +import graphql.ExecutionResult; +import graphql.GraphQL; +import graphql.schema.DataFetchingEnvironment; +import org.junit.jupiter.api.Test; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; + +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.core.io.ClassPathResource; +import org.springframework.graphql.Author; +import org.springframework.graphql.Book; +import org.springframework.graphql.BookCriteria; +import org.springframework.graphql.BookSource; +import org.springframework.graphql.data.method.annotation.Argument; +import org.springframework.graphql.data.method.annotation.GraphQlController; +import org.springframework.graphql.data.method.annotation.MutationMapping; +import org.springframework.graphql.data.method.annotation.QueryMapping; +import org.springframework.graphql.data.method.annotation.SchemaMapping; +import org.springframework.graphql.data.method.annotation.SubscriptionMapping; +import org.springframework.graphql.execution.GraphQlSource; +import org.springframework.http.codec.ServerCodecConfigurer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests with invocation of DataFetcher's from annotated methods. + * @author Rossen Stoyanchev + */ +public class AnnotatedDataFetcherInvocationTests { + + @Test + void queryWithScalarArgument() { + String query = "{ " + + " bookById(id:\"1\") { " + + " id" + + " name" + + " author {" + + " firstName" + + " lastName" + + " }" + + " }" + + "}"; + + ExecutionResult result = initGraphQl(BookController.class).execute(query); + + assertThat(result.getErrors()).isEmpty(); + Map data = result.getData(); + assertThat(data).isNotNull(); + + Map book = getValue(data, "bookById"); + assertThat(book.get("id")).isEqualTo("1"); + assertThat(book.get("name")).isEqualTo("Nineteen Eighty-Four"); + + Map author = getValue(book, "author"); + assertThat(author.get("firstName")).isEqualTo("George"); + assertThat(author.get("lastName")).isEqualTo("Orwell"); + } + + @Test + void queryWithObjectArgument() { + String query = "{ " + + " booksByCriteria(criteria: {author:\"Orwell\"}) { " + + " id" + + " name" + + " }" + + "}"; + + ExecutionResult result = initGraphQl(BookController.class).execute(query); + + assertThat(result.getErrors()).isEmpty(); + Map data = result.getData(); + assertThat(data).isNotNull(); + + List> bookList = getValue(data, "booksByCriteria"); + assertThat(bookList).hasSize(2); + assertThat(bookList.get(0).get("name")).isEqualTo("Nineteen Eighty-Four"); + assertThat(bookList.get(1).get("name")).isEqualTo("Animal Farm"); + } + + @Test + void queryWithArgumentViaDataFetchingEnvironment() { + String query = "{ " + + " authorById(id:\"1\") { " + + " id" + + " firstName" + + " lastName" + + " }" + + "}"; + + ExecutionResult result = initGraphQl(BookController.class).execute(query); + + assertThat(result.getErrors()).isEmpty(); + Map data = result.getData(); + assertThat(data).isNotNull(); + + Map author = getValue(data, "authorById"); + assertThat(author.get("id")).isEqualTo("1"); + assertThat(author.get("firstName")).isEqualTo("George"); + assertThat(author.get("lastName")).isEqualTo("Orwell"); + } + + @Test + void mutation() { + String operation = "mutation { " + + " addAuthor(firstName:\"James\", lastName:\"Joyce\") { " + + " id" + + " firstName" + + " lastName" + + " }" + + "}"; + + ExecutionResult result = initGraphQl(BookController.class).execute(operation); + + assertThat(result.getErrors()).isEmpty(); + Map data = result.getData(); + assertThat(data).isNotNull(); + + Map author = getValue(data, "addAuthor"); + assertThat(author.get("id")).isEqualTo("99"); + assertThat(author.get("firstName")).isEqualTo("James"); + assertThat(author.get("lastName")).isEqualTo("Joyce"); + } + + @Test + void subscription() { + String operation = "subscription { " + + " bookSearch(author:\"Orwell\") { " + + " id" + + " name" + + " }" + + "}"; + + ExecutionResult result = initGraphQl(BookController.class).execute(operation); + + assertThat(result.getErrors()).isEmpty(); + Publisher publisher = result.getData(); + assertThat(publisher).isNotNull(); + + Flux> bookFlux = Flux.from(publisher).map(rs -> { + Map map = rs.getData(); + return (Map) map.get("bookSearch"); + }); + + StepVerifier.create(bookFlux) + .consumeNextWith(book -> { + assertThat(book.get("id")).isEqualTo("1"); + assertThat(book.get("name")).isEqualTo("Nineteen Eighty-Four"); + }) + .consumeNextWith(book -> { + assertThat(book.get("id")).isEqualTo("5"); + assertThat(book.get("name")).isEqualTo("Animal Farm"); + }) + .verifyComplete(); + } + + + private GraphQL initGraphQl(Class beanClass) { + AnnotationConfigApplicationContext applicationContext = new AnnotationConfigApplicationContext(); + applicationContext.registerBean(beanClass); + applicationContext.refresh(); + + AnnotatedDataFetcherRegistrar registrar = new AnnotatedDataFetcherRegistrar(); + registrar.setApplicationContext(applicationContext); + registrar.setServerCodecConfigurer(ServerCodecConfigurer.create()); + registrar.afterPropertiesSet(); + + GraphQlSource graphQlSource = GraphQlSource.builder() + .schemaResources(new ClassPathResource("books/schema.graphqls")) + .configureRuntimeWiring(registrar::register) + .build(); + + return graphQlSource.graphQl(); + } + + @SuppressWarnings("unchecked") + private T getValue(Map data, String key) { + return (T) data.get(key); + } + + + @GraphQlController + private static class BookController { + + @QueryMapping + public Book bookById(@Argument Long id) { + return new Book(id, BookSource.getBook(id).getName(), null); + } + + @QueryMapping + public List booksByCriteria(@Argument BookCriteria criteria) { + return BookSource.findBooksByAuthor(criteria.getAuthor()); + } + + @SchemaMapping + public Author author(Book book) { + return BookSource.getBook(book.getId()).getAuthor(); + } + + @QueryMapping + public Author authorById(DataFetchingEnvironment environment) { + String id = environment.getArgument("id"); + return BookSource.getAuthor(Long.parseLong(id)); + } + + @MutationMapping + public Author addAuthor(@Argument String firstName, @Argument String lastName) { + return new Author(99L, firstName, lastName); + } + + @SubscriptionMapping + public Flux bookSearch(@Argument String author) { + return Flux.fromIterable(BookSource.findBooksByAuthor(author)); + } + } + +} diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/method/AnnotatedDataFetcherRegistrarTests.java b/spring-graphql/src/test/java/org/springframework/graphql/data/method/AnnotatedDataFetcherRegistrarTests.java new file mode 100644 index 00000000..b076d088 --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/method/AnnotatedDataFetcherRegistrarTests.java @@ -0,0 +1,148 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.graphql.data.method; + +import java.util.Map; + +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import graphql.schema.idl.RuntimeWiring; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; + +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.graphql.Author; +import org.springframework.graphql.Book; +import org.springframework.graphql.data.method.annotation.Argument; +import org.springframework.graphql.data.method.annotation.GraphQlController; +import org.springframework.graphql.data.method.annotation.SchemaMapping; +import org.springframework.graphql.data.method.annotation.MutationMapping; +import org.springframework.graphql.data.method.annotation.QueryMapping; +import org.springframework.graphql.data.method.annotation.SubscriptionMapping; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link AnnotatedDataFetcherRegistrar}. + * @author Rossen Stoyanchev + */ +public class AnnotatedDataFetcherRegistrarTests { + + @Test + void registerWithDefaultCoordinates() { + RuntimeWiring.Builder wiringBuilder = initRuntimeWiringBuilder(BookController.class); + + Map> fetcherMap = wiringBuilder.build().getDataFetchers(); + assertThat(fetcherMap).containsOnlyKeys("Query", "Mutation", "Subscription", "Book"); + assertThat(fetcherMap.get("Query")).containsOnlyKeys("bookById", "bookByIdCustomized"); + assertThat(fetcherMap.get("Mutation")).containsOnlyKeys("saveBook", "saveBookCustomized"); + assertThat(fetcherMap.get("Subscription")).containsOnlyKeys("bookSearch", "bookSearchCustomized"); + assertThat(fetcherMap.get("Book")).containsOnlyKeys("author", "authorCustomized"); + + checkMappedMethod(fetcherMap, "Query", "bookById", "bookById"); + checkMappedMethod(fetcherMap, "Mutation", "saveBook", "saveBook"); + checkMappedMethod(fetcherMap, "Subscription", "bookSearch", "bookSearch"); + checkMappedMethod(fetcherMap, "Book", "author", "author"); + } + + @Test + void registerWithExplicitCoordinates() { + RuntimeWiring.Builder wiringBuilder = initRuntimeWiringBuilder(BookController.class); + + Map> fetcherMap = wiringBuilder.build().getDataFetchers(); + assertThat(fetcherMap).containsOnlyKeys("Query", "Mutation", "Subscription", "Book"); + assertThat(fetcherMap.get("Query")).containsOnlyKeys("bookById", "bookByIdCustomized"); + assertThat(fetcherMap.get("Mutation")).containsOnlyKeys("saveBook", "saveBookCustomized"); + assertThat(fetcherMap.get("Subscription")).containsOnlyKeys("bookSearch", "bookSearchCustomized"); + assertThat(fetcherMap.get("Book")).containsOnlyKeys("author", "authorCustomized"); + + checkMappedMethod(fetcherMap, "Query", "bookByIdCustomized", "bookByIdWithNonMatchingMethodName"); + checkMappedMethod(fetcherMap, "Mutation", "saveBookCustomized", "saveBookWithNonMatchingMethodName"); + checkMappedMethod(fetcherMap, "Subscription", "bookSearchCustomized", "bookSearchWithNonMatchingMethodName"); + checkMappedMethod(fetcherMap, "Book", "authorCustomized", "authorWithNonMatchingMethodName"); + } + + private RuntimeWiring.Builder initRuntimeWiringBuilder(Class handlerType) { + AnnotationConfigApplicationContext appContext = new AnnotationConfigApplicationContext(); + appContext.registerBean(handlerType); + appContext.refresh(); + + AnnotatedDataFetcherRegistrar registrar = new AnnotatedDataFetcherRegistrar(); + registrar.setJsonMessageConverter(new MappingJackson2HttpMessageConverter()); + registrar.setApplicationContext(appContext); + registrar.afterPropertiesSet(); + + RuntimeWiring.Builder wiringBuilder = RuntimeWiring.newRuntimeWiring(); + registrar.register(wiringBuilder); + return wiringBuilder; + } + + @SuppressWarnings("rawtypes") + private void checkMappedMethod( + Map> fetcherMap, String typeName, String fieldName, String methodName) { + + AnnotatedDataFetcher fetcher = (AnnotatedDataFetcher) fetcherMap.get(typeName).get(fieldName); + assertThat(fetcher.getHandlerMethod().getMethod().getName()).isEqualTo(methodName); + } + + + @GraphQlController + private static class BookController { + + + @QueryMapping + public Book bookById(@Argument String id) { + return null; + } + + @MutationMapping + public void saveBook(Book book) { + } + + @SubscriptionMapping + public Flux bookSearch(@Argument String author) { + return Flux.empty(); + } + + @SchemaMapping + public Author author(DataFetchingEnvironment environment, Book book) { + return null; + } + + // Field name explicitly specified + + @QueryMapping("bookByIdCustomized") + public Book bookByIdWithNonMatchingMethodName(@Argument String id) { + return null; + } + + @MutationMapping("saveBookCustomized") + public void saveBookWithNonMatchingMethodName(Book book) { + } + + @SubscriptionMapping("bookSearchCustomized") + public Flux bookSearchWithNonMatchingMethodName(@Argument String author) { + return Flux.empty(); + } + + @SchemaMapping("authorCustomized") + public Author authorWithNonMatchingMethodName(Book book) { + return null; + } + } + +} diff --git a/spring-graphql/src/test/resources/books/schema.graphqls b/spring-graphql/src/test/resources/books/schema.graphqls index 3df829fe..d2c34855 100644 --- a/spring-graphql/src/test/resources/books/schema.graphqls +++ b/spring-graphql/src/test/resources/books/schema.graphqls @@ -1,6 +1,21 @@ type Query { bookById(id: ID): Book books(id: ID, name: String, author: String): [Book] + booksByCriteria(criteria:BookCriteria): [Book] + authorById(id: ID): Author +} + +type Mutation { + addAuthor(firstName: String, lastName: String): Author +} + +type Subscription { + bookSearch(author: String) : Book! +} + +input BookCriteria { + id: ID + author: String } type Book { @@ -10,10 +25,7 @@ type Book { } type Author { + id: ID firstName: String lastName: String } - -type Subscription { - bookSearch(author: String) : Book! -} diff --git a/spring-graphql/src/test/resources/log4j2-test.xml b/spring-graphql/src/test/resources/log4j2-test.xml index 7c447623..9d7c9b64 100644 --- a/spring-graphql/src/test/resources/log4j2-test.xml +++ b/spring-graphql/src/test/resources/log4j2-test.xml @@ -7,6 +7,7 @@ +