diff --git a/graphql-dgs/src/main/kotlin/com/netflix/graphql/dgs/exceptions/DuplicateEntityFetcherException.kt b/graphql-dgs/src/main/kotlin/com/netflix/graphql/dgs/exceptions/DuplicateEntityFetcherException.kt new file mode 100644 index 000000000..e81ab1914 --- /dev/null +++ b/graphql-dgs/src/main/kotlin/com/netflix/graphql/dgs/exceptions/DuplicateEntityFetcherException.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2024 Netflix, Inc. + * + * 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 + * + * http://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 com.netflix.graphql.dgs.exceptions + +import java.lang.reflect.Method + +class DuplicateEntityFetcherException( + val entityType: String, + val firstEntityFetcherClass: Class, + val firstEntityFetcherMethod: Method, + val secondEntityFetcherClass: Class, + val secondEntityFetcherMethod: Method, +) : RuntimeException( + "Duplicate EntityFetcherResolver found for entity type $entityType, defined by ${firstEntityFetcherClass.name}.${firstEntityFetcherMethod.name} and ${secondEntityFetcherClass.name}.${secondEntityFetcherMethod.name}", + ) diff --git a/graphql-dgs/src/main/kotlin/com/netflix/graphql/dgs/internal/DgsSchemaProvider.kt b/graphql-dgs/src/main/kotlin/com/netflix/graphql/dgs/internal/DgsSchemaProvider.kt index 7f80f0802..7a7305165 100644 --- a/graphql-dgs/src/main/kotlin/com/netflix/graphql/dgs/internal/DgsSchemaProvider.kt +++ b/graphql-dgs/src/main/kotlin/com/netflix/graphql/dgs/internal/DgsSchemaProvider.kt @@ -20,6 +20,7 @@ import com.apollographql.federation.graphqljava.Federation import com.netflix.graphql.dgs.* import com.netflix.graphql.dgs.exceptions.DataFetcherInputArgumentSchemaMismatchException import com.netflix.graphql.dgs.exceptions.DataFetcherSchemaMismatchException +import com.netflix.graphql.dgs.exceptions.DuplicateEntityFetcherException import com.netflix.graphql.dgs.exceptions.InvalidDgsConfigurationException import com.netflix.graphql.dgs.exceptions.InvalidTypeResolverException import com.netflix.graphql.dgs.exceptions.NoSchemaFoundException @@ -583,15 +584,32 @@ class DgsSchemaProvider( val enableInstrumentation = method.getAnnotation(DgsEnableDataFetcherInstrumentation::class.java)?.value ?: false + val entityFetcherTypeName = dgsEntityFetcherAnnotation.name if (enableInstrumentation) { - val coordinateName = "__entities.${dgsEntityFetcherAnnotation.name}" + val coordinateName = "__entities.$entityFetcherTypeName" dataFetcherInfo.tracingEnabled += coordinateName dataFetcherInfo.metricsEnabled += coordinateName } - entityFetcherRegistry.entityFetchers[dgsEntityFetcherAnnotation.name] = dgsComponent.instance to method + // Throw if an entity fetcher for the same type was already registered + if (entityFetcherRegistry.entityFetchers.contains(entityFetcherTypeName)) { + val firstEntityFetcher = entityFetcherRegistry.entityFetchers[entityFetcherTypeName]!! + + // It's possible the schema() method is invoked multiple times, so check if the second entity fetcher is different from the existing one. + if (firstEntityFetcher != dgsComponent.instance to method) { + throw DuplicateEntityFetcherException( + entityFetcherTypeName, + firstEntityFetcher.first::class.java, + firstEntityFetcher.second, + dgsComponent.instance::class.java, + method, + ) + } + } + + entityFetcherRegistry.entityFetchers[entityFetcherTypeName] = dgsComponent.instance to method - val type = registry.getType(dgsEntityFetcherAnnotation.name) + val type = registry.getType(entityFetcherTypeName) if (enableEntityFetcherCustomScalarParsing) { type.ifPresent { @@ -605,7 +623,7 @@ class DgsSchemaProvider( val fieldsSelection = (fields.value as StringValue).value val paths = SelectionSetUtil.toPaths(fieldsSelection) - entityFetcherRegistry.entityFetcherInputMappings[dgsEntityFetcherAnnotation.name] = + entityFetcherRegistry.entityFetcherInputMappings[entityFetcherTypeName] = paths .asSequence() .mapNotNull { path -> diff --git a/graphql-dgs/src/test/kotlin/com/netflix/graphql/dgs/DuplicateEntityFetcherTest.kt b/graphql-dgs/src/test/kotlin/com/netflix/graphql/dgs/DuplicateEntityFetcherTest.kt new file mode 100644 index 000000000..223e04aa1 --- /dev/null +++ b/graphql-dgs/src/test/kotlin/com/netflix/graphql/dgs/DuplicateEntityFetcherTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2024 Netflix, Inc. + * + * 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 + * + * http://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 com.netflix.graphql.dgs + +import com.netflix.graphql.dgs.DefaultDgsFederationResolverTest.Movie +import com.netflix.graphql.dgs.exceptions.DuplicateEntityFetcherException +import com.netflix.graphql.dgs.internal.DgsSchemaProvider +import com.netflix.graphql.dgs.internal.method.MethodDataFetcherFactory +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.catchThrowable +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.runner.ApplicationContextRunner + +class DuplicateEntityFetcherTest { + @Test + fun `Startup should fail if a duplicate EntityFetcher exists`() { + val contextRunner = + ApplicationContextRunner() + .withBean(DgsSchemaProvider::class.java) + .withBean(EntityFetcherConfig::class.java) + .withBean(MethodDataFetcherFactory::class.java) + + contextRunner.run { context -> + context.start() + + val exception = + catchThrowable { + context.getBean(DgsSchemaProvider::class.java).schema("type Query {}") + } as DuplicateEntityFetcherException + + assertThat( + exception.message, + ).contains( + "Duplicate EntityFetcherResolver found for entity type Movie", + "com.netflix.graphql.dgs.DuplicateEntityFetcherTest\$EntityFetcherConfig.movieEntityFetcher", + "com.netflix.graphql.dgs.DuplicateEntityFetcherTest\$EntityFetcherConfig.anotherMovieEntityFetcher", + ) + assertThat(exception.entityType).isEqualTo("Movie") + assertThat(exception.firstEntityFetcherClass).isEqualTo(EntityFetcherConfig::class.java) + + // The order in which the methods are found can vary, so put the methods in a set to assert + val methods = setOf(exception.firstEntityFetcherMethod.name, exception.secondEntityFetcherMethod.name) + assertThat(exception.secondEntityFetcherClass).isEqualTo(EntityFetcherConfig::class.java) + assertThat(methods).contains("movieEntityFetcher", "anotherMovieEntityFetcher") + } + } + + @DgsComponent + class EntityFetcherConfig { + @DgsEntityFetcher(name = "Movie") + fun movieEntityFetcher(values: Map): Movie = Movie() + + @DgsEntityFetcher(name = "Movie") + fun anotherMovieEntityFetcher(values: Map): Movie = Movie() + } +}