From 82ec7a9bf12583d4dda5487693f51f875197a012 Mon Sep 17 00:00:00 2001 From: gaurav0107 Date: Fri, 15 May 2026 00:40:03 +0530 Subject: [PATCH] feat: allow priority override when multiple SchemaGeneratorHooksProviders are registered Adds a backward-compatible `priority()` default method to the `SchemaGeneratorHooksProvider` SPI (returns `0` by default) and teaches both `generateSDL` and `generateGraalVmReflectMetadata` to pick the provider with the strictly highest priority when more than one provider is discovered through `ServiceLoader`. Behaviour is preserved for existing users: * zero providers still warn and fall back to `NoopSchemaGeneratorHooks` * exactly one provider still wins unchanged * multiple providers at the same (default) priority still fail fast, keeping the legacy error prefix but now listing the tied class names and priority so the conflict can be diagnosed. Library authors that ship a provider which must override a transitively imported one can now opt in by returning a positive value from `priority()` instead of resorting to shade-plugin workarounds. Closes #2150 --- .../hooks/SchemaGeneratorHooksProvider.kt | 13 +++ .../graphql/plugin/schema/GenerateSDL.kt | 18 +-- .../plugin/schema/SelectHooksProvider.kt | 65 +++++++++++ .../plugin/schema/SelectHooksProviderTest.kt | 110 ++++++++++++++++++ .../plugin/graalvm/GenerateGraalVmMetadata.kt | 20 +--- .../plugin/graalvm/SelectHooksProvider.kt | 60 ++++++++++ .../plugin/graalvm/SelectHooksProviderTest.kt | 76 ++++++++++++ 7 files changed, 334 insertions(+), 28 deletions(-) create mode 100644 plugins/schema/graphql-kotlin-sdl-generator/src/main/kotlin/com/expediagroup/graphql/plugin/schema/SelectHooksProvider.kt create mode 100644 plugins/schema/graphql-kotlin-sdl-generator/src/test/kotlin/com/expediagroup/graphql/plugin/schema/SelectHooksProviderTest.kt create mode 100644 plugins/server/graphql-kotlin-graalvm-metadata-generator/src/main/kotlin/com/expediagroup/graphql/plugin/graalvm/SelectHooksProvider.kt create mode 100644 plugins/server/graphql-kotlin-graalvm-metadata-generator/src/test/kotlin/com/expediagroup/graphql/plugin/graalvm/SelectHooksProviderTest.kt 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}" + ) + } +}