diff --git a/plugins/schema/graphql-kotlin-hooks-provider/src/main/kotlin/com/expediagroup/graphql/plugin/schema/hooks/SchemaGeneratorHooksProvider.kt b/plugins/schema/graphql-kotlin-hooks-provider/src/main/kotlin/com/expediagroup/graphql/plugin/schema/hooks/SchemaGeneratorHooksProvider.kt index 0d4275ece6..4a9bba1087 100644 --- a/plugins/schema/graphql-kotlin-hooks-provider/src/main/kotlin/com/expediagroup/graphql/plugin/schema/hooks/SchemaGeneratorHooksProvider.kt +++ b/plugins/schema/graphql-kotlin-hooks-provider/src/main/kotlin/com/expediagroup/graphql/plugin/schema/hooks/SchemaGeneratorHooksProvider.kt @@ -33,4 +33,17 @@ interface SchemaGeneratorHooksProvider { * Create a new instance of a SchemaGeneratorHooks that will be used to generate GraphQL schema in SDL format. */ fun hooks(): SchemaGeneratorHooks + + /** + * Priority used to break ties when multiple `SchemaGeneratorHooksProvider`s are discovered on the classpath. + * + * When more than one provider is found, the one with the strictly highest priority is selected. If multiple + * providers share the highest priority the generator plugins still fail with an error so the conflict is surfaced + * to the user. Providers that do not override this method fall back to the default priority of `0`, which + * preserves the pre-existing fail-fast behaviour for users that have not opted in. + * + * Library authors that ship a `SchemaGeneratorHooksProvider` which must override a transitively-imported + * provider should return a positive value. Larger values win. + */ + fun priority(): Int = 0 } diff --git a/plugins/schema/graphql-kotlin-sdl-generator/src/main/kotlin/com/expediagroup/graphql/plugin/schema/GenerateSDL.kt b/plugins/schema/graphql-kotlin-sdl-generator/src/main/kotlin/com/expediagroup/graphql/plugin/schema/GenerateSDL.kt index bd63ba26b5..844e01a013 100644 --- a/plugins/schema/graphql-kotlin-sdl-generator/src/main/kotlin/com/expediagroup/graphql/plugin/schema/GenerateSDL.kt +++ b/plugins/schema/graphql-kotlin-sdl-generator/src/main/kotlin/com/expediagroup/graphql/plugin/schema/GenerateSDL.kt @@ -44,19 +44,11 @@ private val logger: Logger = LoggerFactory.getLogger("generateSDL") */ fun generateSDL(supportedPackages: List): String { val hooksProviders = ServiceLoader.load(SchemaGeneratorHooksProvider::class.java).toList() - val hooks = when { - hooksProviders.isEmpty() -> { - logger.warn("No SchemaGeneratorHooksProvider were found, defaulting to NoopSchemaGeneratorHooks") - NoopSchemaGeneratorHooks - } - hooksProviders.size > 1 -> { - throw RuntimeException("Cannot generate SDL as multiple SchemaGeneratorHooksProviders were found on the classpath") - } - else -> { - val provider = hooksProviders.first() - logger.debug("SchemaGeneratorHooksProvider found, ${provider.javaClass.simpleName} will be used to generate the hooks") - provider.hooks() - } + val hooks = if (hooksProviders.isEmpty()) { + logger.warn("No SchemaGeneratorHooksProvider were found, defaulting to NoopSchemaGeneratorHooks") + NoopSchemaGeneratorHooks + } else { + selectHooksProvider(hooksProviders, logger).hooks() } val scanResult = ClassGraph() .enableAllInfo() diff --git a/plugins/schema/graphql-kotlin-sdl-generator/src/main/kotlin/com/expediagroup/graphql/plugin/schema/SelectHooksProvider.kt b/plugins/schema/graphql-kotlin-sdl-generator/src/main/kotlin/com/expediagroup/graphql/plugin/schema/SelectHooksProvider.kt new file mode 100644 index 0000000000..56cd772fb8 --- /dev/null +++ b/plugins/schema/graphql-kotlin-sdl-generator/src/main/kotlin/com/expediagroup/graphql/plugin/schema/SelectHooksProvider.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2026 Expedia, 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 + * + * 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 com.expediagroup.graphql.plugin.schema + +import com.expediagroup.graphql.plugin.schema.hooks.SchemaGeneratorHooksProvider +import org.slf4j.Logger + +/** + * Pick the `SchemaGeneratorHooksProvider` that should be used when more than one provider is registered through + * `ServiceLoader`. + * + * Selection rules: + * * When the list contains a single provider it is returned unchanged. + * * When the list contains multiple providers the one with the strictly highest `priority()` wins. + * * When multiple providers tie for the highest priority a `RuntimeException` is thrown so the conflict is surfaced + * to the user, preserving the fail-fast behaviour that existed before priorities were supported. + * + * The caller is expected to handle the empty-list case before invoking this function. + */ +internal fun selectHooksProvider( + providers: List, + logger: Logger +): SchemaGeneratorHooksProvider { + require(providers.isNotEmpty()) { "selectHooksProvider should not be called with an empty list of providers" } + + if (providers.size == 1) { + val provider = providers.single() + logger.debug("SchemaGeneratorHooksProvider found, ${provider.javaClass.simpleName} will be used to generate the hooks") + return provider + } + + val maxPriority = providers.maxOf { it.priority() } + val topProviders = providers.filter { it.priority() == maxPriority } + if (topProviders.size > 1) { + val tiedNames = topProviders.joinToString(", ") { it.javaClass.name } + throw RuntimeException( + "Cannot generate SDL as multiple SchemaGeneratorHooksProviders were found on the classpath with " + + "the same priority ($maxPriority): [$tiedNames]. Override SchemaGeneratorHooksProvider.priority() " + + "on the provider that should win to disambiguate." + ) + } + + val winner = topProviders.single() + val losers = providers.filter { it !== winner } + logger.warn( + "Multiple SchemaGeneratorHooksProviders were found on the classpath; selecting ${winner.javaClass.simpleName} " + + "with priority $maxPriority. Ignored providers: " + + losers.joinToString(", ") { "${it.javaClass.simpleName}(priority=${it.priority()})" } + ) + return winner +} diff --git a/plugins/schema/graphql-kotlin-sdl-generator/src/test/kotlin/com/expediagroup/graphql/plugin/schema/SelectHooksProviderTest.kt b/plugins/schema/graphql-kotlin-sdl-generator/src/test/kotlin/com/expediagroup/graphql/plugin/schema/SelectHooksProviderTest.kt new file mode 100644 index 0000000000..bd0817ae76 --- /dev/null +++ b/plugins/schema/graphql-kotlin-sdl-generator/src/test/kotlin/com/expediagroup/graphql/plugin/schema/SelectHooksProviderTest.kt @@ -0,0 +1,110 @@ +/* + * Copyright 2026 Expedia, 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 + * + * 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 com.expediagroup.graphql.plugin.schema + +import com.expediagroup.graphql.generator.hooks.NoopSchemaGeneratorHooks +import com.expediagroup.graphql.generator.hooks.SchemaGeneratorHooks +import com.expediagroup.graphql.plugin.schema.hooks.SchemaGeneratorHooksProvider +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.slf4j.LoggerFactory +import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class SelectHooksProviderTest { + + private val logger = LoggerFactory.getLogger(SelectHooksProviderTest::class.java) + + private class FixedPriorityProvider(private val priority: Int) : SchemaGeneratorHooksProvider { + override fun hooks(): SchemaGeneratorHooks = NoopSchemaGeneratorHooks + override fun priority(): Int = priority + } + + private class DefaultPriorityProvider : SchemaGeneratorHooksProvider { + override fun hooks(): SchemaGeneratorHooks = NoopSchemaGeneratorHooks + } + + @Test + fun `requires a non-empty provider list`() { + val exception = assertThrows { + selectHooksProvider(emptyList(), logger) + } + assertTrue(exception.message!!.contains("empty list of providers")) + } + + @Test + fun `returns the single provider when only one is registered`() { + val only = DefaultPriorityProvider() + val selected = selectHooksProvider(listOf(only), logger) + assertSame(only, selected) + } + + @Test + fun `selects the provider with the highest priority when multiple are registered`() { + val low = FixedPriorityProvider(priority = 0) + val high = FixedPriorityProvider(priority = 10) + val mid = FixedPriorityProvider(priority = 5) + + val selected = selectHooksProvider(listOf(low, mid, high), logger) + + assertSame(high, selected) + } + + @Test + fun `throws when the highest priority is tied between multiple providers`() { + val first = FixedPriorityProvider(priority = 5) + val second = FixedPriorityProvider(priority = 5) + + val exception = assertThrows { + selectHooksProvider(listOf(first, second), logger) + } + + assertTrue( + exception.message!!.contains("Cannot generate SDL as multiple SchemaGeneratorHooksProviders"), + "expected the legacy error prefix to be preserved but was: ${exception.message}" + ) + assertTrue( + exception.message!!.contains("priority (5)"), + "expected the tied priority value to be in the message but was: ${exception.message}" + ) + assertTrue( + exception.message!!.contains(FixedPriorityProvider::class.java.name), + "expected the tied provider class name to be in the message but was: ${exception.message}" + ) + } + + @Test + fun `throws when all providers share the default priority`() { + val first = DefaultPriorityProvider() + val second = DefaultPriorityProvider() + + val exception = assertThrows { + selectHooksProvider(listOf(first, second), logger) + } + + assertTrue( + exception.message!!.contains("priority (0)"), + "expected tie at default priority (0) to be reported, was: ${exception.message}" + ) + } + + @Test + fun `default priority on the SPI is zero`() { + assertEquals(0, DefaultPriorityProvider().priority()) + } +} diff --git a/plugins/server/graphql-kotlin-graalvm-metadata-generator/src/main/kotlin/com/expediagroup/graphql/plugin/graalvm/GenerateGraalVmMetadata.kt b/plugins/server/graphql-kotlin-graalvm-metadata-generator/src/main/kotlin/com/expediagroup/graphql/plugin/graalvm/GenerateGraalVmMetadata.kt index 5afa048317..96b3b26724 100644 --- a/plugins/server/graphql-kotlin-graalvm-metadata-generator/src/main/kotlin/com/expediagroup/graphql/plugin/graalvm/GenerateGraalVmMetadata.kt +++ b/plugins/server/graphql-kotlin-graalvm-metadata-generator/src/main/kotlin/com/expediagroup/graphql/plugin/graalvm/GenerateGraalVmMetadata.kt @@ -69,21 +69,11 @@ fun generateGraalVmMetadata(targetDirectory: File, supportedPackages: List): List { val hooksProviders = ServiceLoader.load(SchemaGeneratorHooksProvider::class.java).toList() - val hooks = when { - hooksProviders.isEmpty() -> { - logger.warn("No SchemaGeneratorHooksProvider were found, defaulting to NoopSchemaGeneratorHooks") - NoopSchemaGeneratorHooks - } - - hooksProviders.size > 1 -> { - throw RuntimeException("Cannot generate SDL as multiple SchemaGeneratorHooksProviders were found on the classpath") - } - - else -> { - val provider = hooksProviders.first() - logger.debug("SchemaGeneratorHooksProvider found, ${provider.javaClass.simpleName} will be used to generate the hooks") - provider.hooks() - } + val hooks = if (hooksProviders.isEmpty()) { + logger.warn("No SchemaGeneratorHooksProvider were found, defaulting to NoopSchemaGeneratorHooks") + NoopSchemaGeneratorHooks + } else { + selectHooksProvider(hooksProviders, logger).hooks() } val scanResult = ClassGraph() diff --git a/plugins/server/graphql-kotlin-graalvm-metadata-generator/src/main/kotlin/com/expediagroup/graphql/plugin/graalvm/SelectHooksProvider.kt b/plugins/server/graphql-kotlin-graalvm-metadata-generator/src/main/kotlin/com/expediagroup/graphql/plugin/graalvm/SelectHooksProvider.kt new file mode 100644 index 0000000000..b8995339e8 --- /dev/null +++ b/plugins/server/graphql-kotlin-graalvm-metadata-generator/src/main/kotlin/com/expediagroup/graphql/plugin/graalvm/SelectHooksProvider.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2026 Expedia, 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 + * + * 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 com.expediagroup.graphql.plugin.graalvm + +import com.expediagroup.graphql.plugin.schema.hooks.SchemaGeneratorHooksProvider +import org.slf4j.Logger + +/** + * Pick the `SchemaGeneratorHooksProvider` that should be used when more than one provider is registered through + * `ServiceLoader` while generating GraalVM reflect metadata. + * + * Selection rules mirror the SDL generator: the provider with the strictly highest `priority()` wins, and ties + * at the top priority result in a `RuntimeException` so the conflict is visible. + */ +internal fun selectHooksProvider( + providers: List, + logger: Logger +): SchemaGeneratorHooksProvider { + require(providers.isNotEmpty()) { "selectHooksProvider should not be called with an empty list of providers" } + + if (providers.size == 1) { + val provider = providers.single() + logger.debug("SchemaGeneratorHooksProvider found, ${provider.javaClass.simpleName} will be used to generate the hooks") + return provider + } + + val maxPriority = providers.maxOf { it.priority() } + val topProviders = providers.filter { it.priority() == maxPriority } + if (topProviders.size > 1) { + val tiedNames = topProviders.joinToString(", ") { it.javaClass.name } + throw RuntimeException( + "Cannot generate SDL as multiple SchemaGeneratorHooksProviders were found on the classpath with " + + "the same priority ($maxPriority): [$tiedNames]. Override SchemaGeneratorHooksProvider.priority() " + + "on the provider that should win to disambiguate." + ) + } + + val winner = topProviders.single() + val losers = providers.filter { it !== winner } + logger.warn( + "Multiple SchemaGeneratorHooksProviders were found on the classpath; selecting ${winner.javaClass.simpleName} " + + "with priority $maxPriority. Ignored providers: " + + losers.joinToString(", ") { "${it.javaClass.simpleName}(priority=${it.priority()})" } + ) + return winner +} diff --git a/plugins/server/graphql-kotlin-graalvm-metadata-generator/src/test/kotlin/com/expediagroup/graphql/plugin/graalvm/SelectHooksProviderTest.kt b/plugins/server/graphql-kotlin-graalvm-metadata-generator/src/test/kotlin/com/expediagroup/graphql/plugin/graalvm/SelectHooksProviderTest.kt new file mode 100644 index 0000000000..6642de3157 --- /dev/null +++ b/plugins/server/graphql-kotlin-graalvm-metadata-generator/src/test/kotlin/com/expediagroup/graphql/plugin/graalvm/SelectHooksProviderTest.kt @@ -0,0 +1,76 @@ +/* + * Copyright 2026 Expedia, 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 + * + * 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 com.expediagroup.graphql.plugin.graalvm + +import com.expediagroup.graphql.generator.hooks.NoopSchemaGeneratorHooks +import com.expediagroup.graphql.generator.hooks.SchemaGeneratorHooks +import com.expediagroup.graphql.plugin.schema.hooks.SchemaGeneratorHooksProvider +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.slf4j.LoggerFactory +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class SelectHooksProviderTest { + + private val logger = LoggerFactory.getLogger(SelectHooksProviderTest::class.java) + + private class FixedPriorityProvider(private val priority: Int) : SchemaGeneratorHooksProvider { + override fun hooks(): SchemaGeneratorHooks = NoopSchemaGeneratorHooks + override fun priority(): Int = priority + } + + private class DefaultPriorityProvider : SchemaGeneratorHooksProvider { + override fun hooks(): SchemaGeneratorHooks = NoopSchemaGeneratorHooks + } + + @Test + fun `returns the single provider when only one is registered`() { + val only = DefaultPriorityProvider() + val selected = selectHooksProvider(listOf(only), logger) + assertSame(only, selected) + } + + @Test + fun `selects the provider with the highest priority when multiple are registered`() { + val low = FixedPriorityProvider(priority = 0) + val high = FixedPriorityProvider(priority = 10) + + val selected = selectHooksProvider(listOf(low, high), logger) + + assertSame(high, selected) + } + + @Test + fun `throws when the highest priority is tied between multiple providers`() { + val first = FixedPriorityProvider(priority = 5) + val second = FixedPriorityProvider(priority = 5) + + val exception = assertThrows { + selectHooksProvider(listOf(first, second), logger) + } + + assertTrue( + exception.message!!.contains("Cannot generate SDL as multiple SchemaGeneratorHooksProviders"), + "expected the legacy error prefix to be preserved but was: ${exception.message}" + ) + assertTrue( + exception.message!!.contains("priority (5)"), + "expected the tied priority value to be in the message but was: ${exception.message}" + ) + } +}