diff --git a/examples/federation/docker-compose.yaml b/examples/federation/docker-compose.yaml index e7f4bce474..a83df317d6 100644 --- a/examples/federation/docker-compose.yaml +++ b/examples/federation/docker-compose.yaml @@ -1,6 +1,6 @@ services: router: - image: ghcr.io/apollographql/router:v1.10.1 + image: ghcr.io/apollographql/router:v1.29.1 volumes: - ./router.yaml:/dist/config/router.yaml - ./supergraph.graphql:/dist/config/supergraph.graphql diff --git a/examples/federation/supergraph.yaml b/examples/federation/supergraph.yaml index 88ce3cb08f..d9f5e32384 100644 --- a/examples/federation/supergraph.yaml +++ b/examples/federation/supergraph.yaml @@ -1,4 +1,4 @@ -federation_version: =2.4.8 +federation_version: =2.4.13 subgraphs: products: routing_url: http://products:8080/graphql diff --git a/generator/graphql-kotlin-federation/build.gradle.kts b/generator/graphql-kotlin-federation/build.gradle.kts index 5df1d4499f..41fcab7d19 100644 --- a/generator/graphql-kotlin-federation/build.gradle.kts +++ b/generator/graphql-kotlin-federation/build.gradle.kts @@ -19,12 +19,12 @@ tasks { limit { counter = "INSTRUCTION" value = "COVEREDRATIO" - minimum = "0.96".toBigDecimal() + minimum = "0.95".toBigDecimal() } limit { counter = "BRANCH" value = "COVEREDRATIO" - minimum = "0.90".toBigDecimal() + minimum = "0.80".toBigDecimal() } } } diff --git a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/FederatedSchemaGeneratorHooks.kt b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/FederatedSchemaGeneratorHooks.kt index 9bae71de0b..4e0363a51d 100644 --- a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/FederatedSchemaGeneratorHooks.kt +++ b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/FederatedSchemaGeneratorHooks.kt @@ -18,34 +18,45 @@ package com.expediagroup.graphql.generator.federation import com.apollographql.federation.graphqljava.printer.ServiceSDLPrinter.generateServiceSDL import com.apollographql.federation.graphqljava.printer.ServiceSDLPrinter.generateServiceSDLV2 +import com.expediagroup.graphql.generator.TopLevelObject import com.expediagroup.graphql.generator.annotations.GraphQLName import com.expediagroup.graphql.generator.directives.DirectiveMetaInformation import com.expediagroup.graphql.generator.federation.directives.COMPOSE_DIRECTIVE_NAME -import com.expediagroup.graphql.generator.federation.directives.COMPOSE_DIRECTIVE_TYPE +import com.expediagroup.graphql.generator.federation.directives.CONTACT_DIRECTIVE_NAME +import com.expediagroup.graphql.generator.federation.directives.CONTACT_DIRECTIVE_TYPE +import com.expediagroup.graphql.generator.federation.directives.EXTENDS_DIRECTIVE_NAME import com.expediagroup.graphql.generator.federation.directives.EXTENDS_DIRECTIVE_TYPE import com.expediagroup.graphql.generator.federation.directives.EXTERNAL_DIRECTIVE_NAME import com.expediagroup.graphql.generator.federation.directives.EXTERNAL_DIRECTIVE_TYPE import com.expediagroup.graphql.generator.federation.directives.EXTERNAL_DIRECTIVE_TYPE_V2 -import com.expediagroup.graphql.generator.federation.directives.FEDERATION_SPEC_URL +import com.expediagroup.graphql.generator.federation.directives.FEDERATION_SPEC +import com.expediagroup.graphql.generator.federation.directives.FEDERATION_SPEC_LATEST_URL +import com.expediagroup.graphql.generator.federation.directives.FEDERATION_SPEC_URL_PREFIX import com.expediagroup.graphql.generator.federation.directives.FieldSet import com.expediagroup.graphql.generator.federation.directives.INACCESSIBLE_DIRECTIVE_NAME -import com.expediagroup.graphql.generator.federation.directives.INACCESSIBLE_DIRECTIVE_TYPE import com.expediagroup.graphql.generator.federation.directives.INTERFACE_OBJECT_DIRECTIVE_NAME -import com.expediagroup.graphql.generator.federation.directives.INTERFACE_OBJECT_DIRECTIVE_TYPE import com.expediagroup.graphql.generator.federation.directives.KEY_DIRECTIVE_NAME import com.expediagroup.graphql.generator.federation.directives.KEY_DIRECTIVE_TYPE -import com.expediagroup.graphql.generator.federation.directives.KEY_DIRECTIVE_TYPE_V2 import com.expediagroup.graphql.generator.federation.directives.LINK_DIRECTIVE_NAME -import com.expediagroup.graphql.generator.federation.directives.LINK_DIRECTIVE_TYPE +import com.expediagroup.graphql.generator.federation.directives.LINK_SPEC +import com.expediagroup.graphql.generator.federation.directives.LinkDirective +import com.expediagroup.graphql.generator.federation.directives.LinkImport +import com.expediagroup.graphql.generator.federation.directives.LinkedSpec import com.expediagroup.graphql.generator.federation.directives.OVERRIDE_DIRECTIVE_NAME -import com.expediagroup.graphql.generator.federation.directives.OVERRIDE_DIRECTIVE_TYPE +import com.expediagroup.graphql.generator.federation.directives.PROVIDES_DIRECTIVE_NAME import com.expediagroup.graphql.generator.federation.directives.PROVIDES_DIRECTIVE_TYPE +import com.expediagroup.graphql.generator.federation.directives.REQUIRES_DIRECTIVE_NAME import com.expediagroup.graphql.generator.federation.directives.REQUIRES_DIRECTIVE_TYPE import com.expediagroup.graphql.generator.federation.directives.SHAREABLE_DIRECTIVE_NAME -import com.expediagroup.graphql.generator.federation.directives.SHAREABLE_DIRECTIVE_TYPE -import com.expediagroup.graphql.generator.federation.directives.TAG_DIRECTIVE_TYPE -import com.expediagroup.graphql.generator.federation.directives.appliedLinkDirective +import com.expediagroup.graphql.generator.federation.directives.TAG_DIRECTIVE_NAME +import com.expediagroup.graphql.generator.federation.directives.keyDirectiveDefinition +import com.expediagroup.graphql.generator.federation.directives.linkDirectiveDefinition +import com.expediagroup.graphql.generator.federation.directives.providesDirectiveDefinition +import com.expediagroup.graphql.generator.federation.directives.requiresDirectiveDefinition +import com.expediagroup.graphql.generator.federation.directives.toAppliedLinkDirective +import com.expediagroup.graphql.generator.federation.exception.DuplicateSpecificationLinkImport import com.expediagroup.graphql.generator.federation.exception.IncorrectFederatedDirectiveUsage +import com.expediagroup.graphql.generator.federation.exception.UnknownSpecificationException import com.expediagroup.graphql.generator.federation.execution.EntitiesDataFetcher import com.expediagroup.graphql.generator.federation.execution.FederatedTypeResolver import com.expediagroup.graphql.generator.federation.types.ANY_SCALAR_TYPE @@ -53,6 +64,7 @@ import com.expediagroup.graphql.generator.federation.types.ENTITY_UNION_NAME import com.expediagroup.graphql.generator.federation.types.FIELD_SET_SCALAR_NAME import com.expediagroup.graphql.generator.federation.types.FIELD_SET_SCALAR_TYPE import com.expediagroup.graphql.generator.federation.types.FieldSetTransformer +import com.expediagroup.graphql.generator.federation.types.LINK_IMPORT_SCALAR_TYPE import com.expediagroup.graphql.generator.federation.types.SERVICE_FIELD_DEFINITION import com.expediagroup.graphql.generator.federation.types._Service import com.expediagroup.graphql.generator.federation.types.generateEntityFieldDefinition @@ -63,10 +75,14 @@ import graphql.schema.DataFetcher import graphql.schema.FieldCoordinates import graphql.schema.GraphQLCodeRegistry import graphql.schema.GraphQLDirective +import graphql.schema.GraphQLNamedType import graphql.schema.GraphQLObjectType +import graphql.schema.GraphQLScalarType import graphql.schema.GraphQLSchema import graphql.schema.GraphQLType import graphql.schema.SchemaTransformer +import java.nio.file.Paths +import kotlin.io.path.name import kotlin.reflect.KType import kotlin.reflect.full.findAnnotation @@ -77,7 +93,9 @@ open class FederatedSchemaGeneratorHooks( private val resolvers: List, private val optInFederationV2: Boolean = true ) : SchemaGeneratorHooks { - private val validator = FederatedSchemaValidator() + private val validator: FederatedSchemaValidator = FederatedSchemaValidator() + data class LinkSpec(val namespace: String, val imports: Map) + private val linkSpecs: MutableMap = HashMap() private val federationV2OnlyDirectiveNames: Set = setOf( COMPOSE_DIRECTIVE_NAME, @@ -95,26 +113,103 @@ open class FederatedSchemaGeneratorHooks( PROVIDES_DIRECTIVE_TYPE, REQUIRES_DIRECTIVE_TYPE ) - private val federatedDirectiveV2List: List = listOf( - COMPOSE_DIRECTIVE_TYPE, - EXTENDS_DIRECTIVE_TYPE, - EXTERNAL_DIRECTIVE_TYPE_V2, - INACCESSIBLE_DIRECTIVE_TYPE, - INTERFACE_OBJECT_DIRECTIVE_TYPE, - KEY_DIRECTIVE_TYPE_V2, - LINK_DIRECTIVE_TYPE, - OVERRIDE_DIRECTIVE_TYPE, - PROVIDES_DIRECTIVE_TYPE, - REQUIRES_DIRECTIVE_TYPE, - SHAREABLE_DIRECTIVE_TYPE, - TAG_DIRECTIVE_TYPE - ) + + // workaround to https://github.com/ExpediaGroup/graphql-kotlin/issues/1815 + // since those scalars can be renamed, we need to ensure we only generate those scalars just once + private val fieldSetScalar: GraphQLScalarType by lazy { + if (optInFederationV2) { + FIELD_SET_SCALAR_TYPE.run { + val fieldSetScalarName = namespacedTypeName(FEDERATION_SPEC, this.name) + if (fieldSetScalarName != this.name) { + return@run this.transform { it.name(fieldSetScalarName) } + } else { + this + } + } + } else { + FIELD_SET_SCALAR_TYPE + } + } + private val linkImportScalar: GraphQLScalarType by lazy { + LINK_IMPORT_SCALAR_TYPE.run { + val importScalarName = namespacedTypeName(LINK_SPEC, this.name) + if (importScalarName != this.name) { + this.transform { + it.name(importScalarName) + } + } else { + this + } + } + } + + override fun willBuildSchema( + queries: List, + mutations: List, + subscriptions: List, + additionalTypes: Set, + additionalInputTypes: Set, + schemaObject: TopLevelObject? + ): GraphQLSchema.Builder { + if (optInFederationV2) { + // preprocess any @LinkDirective applications to capture namespaces for all the imported specs + val appliedLinkDirectives = schemaObject?.kClass?.annotations?.filterIsInstance(LinkDirective::class.java) + appliedLinkDirectives?.forEach { appliedDirectiveAnnotation -> + val specUrl = Paths.get(appliedDirectiveAnnotation.url) + val spec = specUrl.parent.fileName.name + + if (linkSpecs.containsKey(spec)) { + throw DuplicateSpecificationLinkImport(spec, appliedDirectiveAnnotation.url) + } else { + val nameSpace: String = appliedDirectiveAnnotation.`as`.takeIf { + it.isNotBlank() + } ?: spec + val imports: Map = appliedDirectiveAnnotation.import.associate { import -> + val importedName = import.`as`.takeIf { it.isNotBlank() } ?: import.name + normalizeImportName(import.name) to normalizeImportName(importedName) + } + + val linkSpec = LinkSpec(nameSpace, imports) + linkSpecs[spec] = linkSpec + } + } + + // populate defaults + if (!linkSpecs.containsKey(FEDERATION_SPEC)) { + linkSpecs[FEDERATION_SPEC] = LinkSpec( + FEDERATION_SPEC, + listOf( + COMPOSE_DIRECTIVE_NAME, + EXTENDS_DIRECTIVE_NAME, + EXTERNAL_DIRECTIVE_NAME, + INACCESSIBLE_DIRECTIVE_NAME, + INTERFACE_OBJECT_DIRECTIVE_NAME, + KEY_DIRECTIVE_NAME, + OVERRIDE_DIRECTIVE_NAME, + PROVIDES_DIRECTIVE_NAME, + REQUIRES_DIRECTIVE_NAME, + SHAREABLE_DIRECTIVE_NAME, + TAG_DIRECTIVE_NAME, + FIELD_SET_SCALAR_NAME + ).associateWith { it } + ) + } + if (!linkSpecs.containsKey(LINK_SPEC)) { + linkSpecs[LINK_SPEC] = LinkSpec(LINK_SPEC, emptyMap()) + } + } + + return super.willBuildSchema(queries, mutations, subscriptions, additionalTypes, additionalInputTypes, schemaObject) + } + + private fun normalizeImportName(name: String) = name.replace("@", "") /** - * Add support for _FieldSet scalar to the schema. + * Add support for FieldSet and LinkImport scalars to the schema. */ override fun willGenerateGraphQLType(type: KType): GraphQLType? = when (type.classifier) { - FieldSet::class -> FIELD_SET_SCALAR_TYPE + FieldSet::class -> fieldSetScalar + LinkImport::class -> linkImportScalar else -> super.willGenerateGraphQLType(type) } @@ -127,15 +222,19 @@ open class FederatedSchemaGeneratorHooks( private fun willGenerateFederatedDirective(directiveInfo: DirectiveMetaInformation): GraphQLDirective? = when { federationV2OnlyDirectiveNames.contains(directiveInfo.effectiveName) -> throw IncorrectFederatedDirectiveUsage(directiveInfo.effectiveName) + CONTACT_DIRECTIVE_NAME == directiveInfo.effectiveName -> CONTACT_DIRECTIVE_TYPE EXTERNAL_DIRECTIVE_NAME == directiveInfo.effectiveName -> EXTERNAL_DIRECTIVE_TYPE KEY_DIRECTIVE_NAME == directiveInfo.effectiveName -> KEY_DIRECTIVE_TYPE else -> super.willGenerateDirective(directiveInfo) } private fun willGenerateFederatedDirectiveV2(directiveInfo: DirectiveMetaInformation): GraphQLDirective? = when (directiveInfo.effectiveName) { + CONTACT_DIRECTIVE_NAME -> CONTACT_DIRECTIVE_TYPE EXTERNAL_DIRECTIVE_NAME -> EXTERNAL_DIRECTIVE_TYPE_V2 - KEY_DIRECTIVE_NAME -> KEY_DIRECTIVE_TYPE_V2 - LINK_DIRECTIVE_NAME -> LINK_DIRECTIVE_TYPE + KEY_DIRECTIVE_NAME -> keyDirectiveDefinition(fieldSetScalar) + LINK_DIRECTIVE_NAME -> linkDirectiveDefinition(linkImportScalar) + PROVIDES_DIRECTIVE_NAME -> providesDirectiveDefinition(fieldSetScalar) + REQUIRES_DIRECTIVE_NAME -> requiresDirectiveDefinition(fieldSetScalar) else -> super.willGenerateDirective(directiveInfo) } @@ -144,6 +243,30 @@ open class FederatedSchemaGeneratorHooks( return super.didGenerateGraphQLType(type, generatedType) } + override fun didGenerateDirective(directiveInfo: DirectiveMetaInformation, directive: GraphQLDirective): GraphQLDirective { + if (optInFederationV2) { + // namespace generated directive if needed + val linkedSpec = directiveInfo.directive.annotationClass.annotations + .filterIsInstance(LinkedSpec::class.java) + .map { it.value } + .firstOrNull() + if (linkedSpec != null) { + val finalName = namespacedTypeName(linkedSpec, directive.name) + if (finalName != directive.name) { + return directive.transform { + it.name(finalName) + } + } + } + } + return super.didGenerateDirective(directiveInfo, directive) + } + + private fun namespacedTypeName(specification: String, name: String): String { + val spec = linkSpecs[specification] ?: throw UnknownSpecificationException(name, specification) + return spec.imports[name] ?: "${spec.namespace}__$name" + } + override fun didBuildSchema(builder: GraphQLSchema.Builder): GraphQLSchema.Builder { val originalSchema = builder.build() val originalQuery = originalSchema.queryType @@ -152,12 +275,33 @@ open class FederatedSchemaGeneratorHooks( builder.additionalDirective(it) } if (optInFederationV2) { - val fed2Imports = federatedDirectiveV2List.map { "@${it.name}" } - .minus("@$LINK_DIRECTIVE_NAME") - .plus(FIELD_SET_SCALAR_NAME) + // apply @link federation spec import only if it was not yet specified + val federationSpecImportExists = originalSchema.schemaAppliedDirectives.filter { it.name == "link" }.any { + it.getArgument("url")?.argumentValue?.value?.toString()?.startsWith(FEDERATION_SPEC_URL_PREFIX) == true + } + if (!federationSpecImportExists) { + val fed2Imports = linkSpecs[FEDERATION_SPEC]?.imports + ?.keys + ?.mapNotNull { + val directive = originalSchema.getDirective(it) + if (directive != null) { + return@mapNotNull "@${directive.name}" + } - builder.withSchemaDirective(LINK_DIRECTIVE_TYPE) - .withSchemaAppliedDirective(appliedLinkDirective(FEDERATION_SPEC_URL, fed2Imports)) + val scalar = originalSchema.getType(it) as? GraphQLNamedType + if (scalar != null) { + return@mapNotNull scalar.name + } + null + } + ?: emptyList() + val linkDirective = linkDirectiveDefinition(linkImportScalar) + if (!originalSchema.directives.any { it.name == LINK_DIRECTIVE_NAME }) { + // only add @link directive definition if it doesn't exist yet + builder.additionalDirective(linkDirective) + } + builder.withSchemaAppliedDirective(linkDirective.toAppliedLinkDirective(FEDERATION_SPEC_LATEST_URL, null, fed2Imports)) + } } val federatedCodeRegistry = GraphQLCodeRegistry.newCodeRegistry(originalSchema.codeRegistry) @@ -191,19 +335,16 @@ open class FederatedSchemaGeneratorHooks( return federatedBuilder.codeRegistry(federatedCodeRegistry.build()) } - private fun findMissingFederationDirectives(existingDirectives: List): List { + private fun findMissingFederationDirectives(existingDirectives: List): List = if (optInFederationV2) { + emptyList() + } else { + // we auto-add directive definitions only for fed v1 schemas val existingDirectiveNames = existingDirectives.map { it.name } - return federatedDirectiveList().filter { + federatedDirectiveV1List.filter { !existingDirectiveNames.contains(it.name) } } - private fun federatedDirectiveList(): List = if (optInFederationV2) { - federatedDirectiveV2List - } else { - federatedDirectiveV1List - } - /** * Federated service may not have any regular queries but will have federated queries. In order to ensure that we * have a valid GraphQL schema that can be modified in the [didBuildSchema], query has to have at least one single field. @@ -220,17 +361,10 @@ open class FederatedSchemaGeneratorHooks( .build() /** - * Get the modified SDL returned by _service field - * - * It should NOT contain: - * - default schema definition - * - empty Query type - * - any directive definitions - * - any custom directives - * - new federated scalars + * Generate SDL that will be returned by _service field * * See the federation spec for more details: - * https://www.apollographql.com/docs/apollo-server/federation/federation-spec/#query_service + * https://www.apollographql.com/docs/federation/subgraph-spec/#enhanced-introspection-with-query_service */ private fun getFederatedServiceSdl(schema: GraphQLSchema): String { return if (optInFederationV2) { @@ -247,10 +381,15 @@ open class FederatedSchemaGeneratorHooks( * https://www.apollographql.com/docs/apollo-server/federation/federation-spec/#union-_entity */ private fun getFederatedEntities(originalSchema: GraphQLSchema): Set { + val keyDirectiveName = if (optInFederationV2) { + namespacedTypeName(FEDERATION_SPEC, KEY_DIRECTIVE_NAME) + } else { + KEY_DIRECTIVE_NAME + } return originalSchema.allTypesAsList .asSequence() .filterIsInstance() - .filter { type -> type.hasAppliedDirective(KEY_DIRECTIVE_NAME) } + .filter { type -> type.hasAppliedDirective(keyDirectiveName) } .map { it.name } .toSet() } diff --git a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/ComposeDirective.kt b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/ComposeDirective.kt index dd5ed8ed16..a24ba080a6 100644 --- a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/ComposeDirective.kt +++ b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/ComposeDirective.kt @@ -17,10 +17,7 @@ package com.expediagroup.graphql.generator.federation.directives import com.expediagroup.graphql.generator.annotations.GraphQLDirective -import graphql.Scalars import graphql.introspection.Introspection -import graphql.schema.GraphQLArgument -import graphql.schema.GraphQLNonNull /** * ```graphql @@ -37,6 +34,7 @@ import graphql.schema.GraphQLNonNull * annotation class CustomDirective * * @ComposeDirective(name = "custom") + * @LinkDirective() * class CustomSchema * * class SimpleQuery { @@ -48,7 +46,7 @@ import graphql.schema.GraphQLNonNull * it will generate following schema * * ```graphql - * schema @composeDirective(name: "@myDirective") @link(import : ["@composeDirective", "@extends", "@external", "@inaccessible", "@interfaceObject", "@key", "@override", "@provides", "@requires", "@shareable", "@tag", "FieldSet"], url : "https://specs.apollo.dev/federation/v2.3"){ + * schema @composeDirective(name: "@custom") @link(import : ["@composeDirective", "@extends", "@external", "@inaccessible", "@interfaceObject", "@key", "@override", "@provides", "@requires", "@shareable", "@tag", "FieldSet"], url : "https://specs.apollo.dev/federation/v2.3"){ * query: Query * } * @@ -61,6 +59,7 @@ import graphql.schema.GraphQLNonNull * * @see @composeDirective definition */ +@LinkedSpec(FEDERATION_SPEC) @Repeatable @GraphQLDirective( name = COMPOSE_DIRECTIVE_NAME, @@ -71,15 +70,3 @@ annotation class ComposeDirective(val name: String) internal const val COMPOSE_DIRECTIVE_NAME = "composeDirective" private const val COMPOSE_DIRECTIVE_DESCRIPTION = "Marks underlying custom directive to be included in the Supergraph schema" - -internal val COMPOSE_DIRECTIVE_TYPE: graphql.schema.GraphQLDirective = graphql.schema.GraphQLDirective.newDirective() - .name(COMPOSE_DIRECTIVE_NAME) - .description(COMPOSE_DIRECTIVE_DESCRIPTION) - .validLocations(Introspection.DirectiveLocation.SCHEMA) - .argument( - GraphQLArgument.newArgument() - .name("name") - .type(GraphQLNonNull.nonNull(Scalars.GraphQLString)) - ) - .repeatable(true) - .build() diff --git a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/ContactDirective.kt b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/ContactDirective.kt index 976ca776e4..52d4bdf8e6 100644 --- a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/ContactDirective.kt +++ b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/ContactDirective.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 Expedia, Inc + * Copyright 2023 Expedia, Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,7 +17,10 @@ package com.expediagroup.graphql.generator.federation.directives import com.expediagroup.graphql.generator.annotations.GraphQLDirective +import graphql.Scalars import graphql.introspection.Introspection.DirectiveLocation +import graphql.schema.GraphQLArgument +import graphql.schema.GraphQLNonNull /** * ```graphql @@ -65,3 +68,27 @@ annotation class ContactDirective( internal const val CONTACT_DIRECTIVE_NAME = "contact" private const val CONTACT_DIRECTIVE_DESCRIPTION = "Provides contact information of the owner responsible for this subgraph schema." + +internal val CONTACT_DIRECTIVE_TYPE: graphql.schema.GraphQLDirective = graphql.schema.GraphQLDirective.newDirective() + .name(CONTACT_DIRECTIVE_NAME) + .description(CONTACT_DIRECTIVE_DESCRIPTION) + .validLocations(DirectiveLocation.SCHEMA) + .argument( + GraphQLArgument.newArgument() + .name("name") + .type(GraphQLNonNull.nonNull(Scalars.GraphQLString)) + .build() + ) + .argument( + GraphQLArgument.newArgument() + .name("url") + .type(Scalars.GraphQLString) + .build() + ) + .argument( + GraphQLArgument.newArgument() + .name("description") + .type(Scalars.GraphQLString) + .build() + ) + .build() diff --git a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/ExtendsDirective.kt b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/ExtendsDirective.kt index 14171d8522..345e4f269d 100644 --- a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/ExtendsDirective.kt +++ b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/ExtendsDirective.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 Expedia, Inc + * Copyright 2023 Expedia, Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -52,6 +52,7 @@ import graphql.introspection.Introspection.DirectiveLocation * * @see KeyDirective */ +@LinkedSpec(FEDERATION_SPEC) @Deprecated(message = "@extends is only required in Federation v1 and can be safely omitted from Federation v2 schemas") @GraphQLDirective( name = EXTENDS_DIRECTIVE_NAME, diff --git a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/ExternalDirective.kt b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/ExternalDirective.kt index 587e3c58dd..66891d83df 100644 --- a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/ExternalDirective.kt +++ b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/ExternalDirective.kt @@ -61,6 +61,7 @@ import graphql.introspection.Introspection.DirectiveLocation * @see KeyDirective * @see RequiresDirective */ +@LinkedSpec(FEDERATION_SPEC) @GraphQLDirective( name = EXTERNAL_DIRECTIVE_NAME, description = EXTERNAL_DIRECTIVE_DESCRIPTION, diff --git a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/FieldSet.kt b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/FieldSet.kt index 35243e2875..5325a304fc 100644 --- a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/FieldSet.kt +++ b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/FieldSet.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 Expedia, Inc + * Copyright 2023 Expedia, Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,7 +17,7 @@ package com.expediagroup.graphql.generator.federation.directives /** - * Annotation representing _FieldSet scalar type that is used to represent a set of fields. + * Annotation representing FieldSet scalar type that is used to represent a set of fields. * * Field set can represent: * - single field, e.g. "id" @@ -28,4 +28,5 @@ package com.expediagroup.graphql.generator.federation.directives * * @see [com.expediagroup.graphql.generator.federation.types.FIELD_SET_SCALAR_TYPE] */ +@LinkedSpec(FEDERATION_SPEC) annotation class FieldSet(val value: String) diff --git a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/InaccessibleDirective.kt b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/InaccessibleDirective.kt index 3625520431..f2dbba2d6b 100644 --- a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/InaccessibleDirective.kt +++ b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/InaccessibleDirective.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 Expedia, Inc + * Copyright 2023 Expedia, Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -68,6 +68,7 @@ import graphql.introspection.Introspection.DirectiveLocation * * @see @inaccessible specification */ +@LinkedSpec(FEDERATION_SPEC) @GraphQLDirective( name = INACCESSIBLE_DIRECTIVE_NAME, description = INACESSIBLE_DIRECTIVE_DESCRIPTION, @@ -88,20 +89,3 @@ annotation class InaccessibleDirective internal const val INACCESSIBLE_DIRECTIVE_NAME = "inaccessible" private const val INACESSIBLE_DIRECTIVE_DESCRIPTION = "Marks location within schema as inaccessible from the GraphQL Gateway" - -internal val INACCESSIBLE_DIRECTIVE_TYPE: graphql.schema.GraphQLDirective = graphql.schema.GraphQLDirective.newDirective() - .name(INACCESSIBLE_DIRECTIVE_NAME) - .description(INACESSIBLE_DIRECTIVE_DESCRIPTION) - .validLocations( - DirectiveLocation.FIELD_DEFINITION, - DirectiveLocation.OBJECT, - DirectiveLocation.INTERFACE, - DirectiveLocation.UNION, - DirectiveLocation.ENUM, - DirectiveLocation.ENUM_VALUE, - DirectiveLocation.SCALAR, - DirectiveLocation.INPUT_OBJECT, - DirectiveLocation.INPUT_FIELD_DEFINITION, - DirectiveLocation.ARGUMENT_DEFINITION - ) - .build() diff --git a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/InterfaceObjectDirective.kt b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/InterfaceObjectDirective.kt index 6c1eade691..bc452536d8 100644 --- a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/InterfaceObjectDirective.kt +++ b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/InterfaceObjectDirective.kt @@ -67,6 +67,7 @@ import graphql.introspection.Introspection * } * ``` */ +@LinkedSpec(FEDERATION_SPEC) @GraphQLDirective( name = INTERFACE_OBJECT_DIRECTIVE_NAME, description = INTERFACE_OBJECT_DIRECTIVE_DESCRIPTION, @@ -76,9 +77,3 @@ annotation class InterfaceObjectDirective internal const val INTERFACE_OBJECT_DIRECTIVE_NAME = "interfaceObject" private const val INTERFACE_OBJECT_DIRECTIVE_DESCRIPTION = "Provides meta information to the router that this entity type is an interface in the supergraph." - -internal val INTERFACE_OBJECT_DIRECTIVE_TYPE: graphql.schema.GraphQLDirective = graphql.schema.GraphQLDirective.newDirective() - .name(INTERFACE_OBJECT_DIRECTIVE_NAME) - .description(INTERFACE_OBJECT_DIRECTIVE_DESCRIPTION) - .validLocations(Introspection.DirectiveLocation.OBJECT) - .build() diff --git a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/KeyDirective.kt b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/KeyDirective.kt index d8a44d128a..84914ed330 100644 --- a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/KeyDirective.kt +++ b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/KeyDirective.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 Expedia, Inc + * Copyright 2023 Expedia, Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,9 +18,11 @@ package com.expediagroup.graphql.generator.federation.directives import com.expediagroup.graphql.generator.annotations.GraphQLDirective import com.expediagroup.graphql.generator.federation.types.FIELD_SET_ARGUMENT +import com.expediagroup.graphql.generator.federation.types.fieldSetArgumentDefinition import graphql.Scalars import graphql.introspection.Introspection.DirectiveLocation import graphql.schema.GraphQLArgument +import graphql.schema.GraphQLScalarType /** * ```graphql @@ -89,6 +91,7 @@ import graphql.schema.GraphQLArgument * @see FieldSet * @see ExternalDirective */ +@LinkedSpec(FEDERATION_SPEC) @Repeatable @GraphQLDirective( name = KEY_DIRECTIVE_NAME, @@ -108,11 +111,11 @@ internal val KEY_DIRECTIVE_TYPE: graphql.schema.GraphQLDirective = graphql.schem .repeatable(true) .build() -internal val KEY_DIRECTIVE_TYPE_V2: graphql.schema.GraphQLDirective = graphql.schema.GraphQLDirective.newDirective() +internal fun keyDirectiveDefinition(fieldSetScalar: GraphQLScalarType): graphql.schema.GraphQLDirective = graphql.schema.GraphQLDirective.newDirective() .name(KEY_DIRECTIVE_NAME) .description(KEY_DIRECTIVE_DESCRIPTION) .validLocations(DirectiveLocation.OBJECT, DirectiveLocation.INTERFACE) - .argument(FIELD_SET_ARGUMENT) + .argument(fieldSetArgumentDefinition(fieldSetScalar)) .argument( GraphQLArgument.newArgument() .name("resolvable") diff --git a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/LinkDirective.kt b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/LinkDirective.kt index e59c7f5cd3..409a3c9587 100644 --- a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/LinkDirective.kt +++ b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/LinkDirective.kt @@ -22,26 +22,36 @@ import graphql.introspection.Introspection.DirectiveLocation import graphql.schema.GraphQLArgument import graphql.schema.GraphQLList import graphql.schema.GraphQLNonNull +import graphql.schema.GraphQLScalarType -const val LINK_SPEC_URL = "https://specs.apollo.dev/link/v1.0/" -const val FEDERATION_SPEC_URL = "https://specs.apollo.dev/federation/v2.3" +const val APOLLO_SPEC_URL = "https://specs.apollo.dev" + +const val LINK_SPEC = "link" +const val LINK_SPEC_LATEST_VERSION = "1.0" +const val LINK_SPEC_URL_PREFIX = "$APOLLO_SPEC_URL/$LINK_SPEC" +const val LINK_SPEC_LATEST_URL = "$LINK_SPEC_URL_PREFIX/v$LINK_SPEC_LATEST_VERSION" + +const val FEDERATION_SPEC = "federation" +const val FEDERATION_SPEC_LATEST_VERSION = "2.3" +const val FEDERATION_SPEC_URL_PREFIX = "$APOLLO_SPEC_URL/$FEDERATION_SPEC" +const val FEDERATION_SPEC_LATEST_URL = "$FEDERATION_SPEC_URL_PREFIX/v$FEDERATION_SPEC_LATEST_VERSION" /** * ```graphql - * directive @link(url: String!, import: [Import]) repeatable on SCHEMA + * directive @link(url: String!, as: String, import: [Import]) repeatable on SCHEMA * ``` * * The `@link` directive links definitions within the document to external schemas. * - * External schemas are identified by their url, which optionally ends with a name and version with the following format: `{NAME}/v{MAJOR}.{MINOR}` + * External schemas are identified by their url, which should end with a specification name and version with the following format: `{NAME}/v{MAJOR}.{MINOR}`, e.g. `url = "https://specs.apollo.dev/federation/v2.3"`. * - * By default, external types should be namespaced (prefixed with namespace__, e.g. key directive should be namespaced as federation__key) unless they are explicitly imported. `graphql-kotlin` - * automatically imports ALL federation directives to avoid the need for namespacing. - * - * >NOTE: We currently DO NOT support full `@link` directive capability as it requires support for namespacing and renaming imports. This functionality may be added in the future releases. See `@link` - * specification for details. + * External types are associated with the target specification by annotating it with `@LinkedSpec` meta annotation. External types defined in the specification will be automatically namespaced + * (prefixed with `{NAME}__`) unless they are explicitly imported. While both custom namespace (`as`) and import arguments are optional, due to https://github.com/ExpediaGroup/graphql-kotlin/issues/1830 + * we currently always require those values to be explicitly provided. * * @param url external schema URL + * @param as custom namespace, should default to the specification name specified in the url + * @param import list of imported schema elements * * @see @link specification */ @@ -51,12 +61,12 @@ const val FEDERATION_SPEC_URL = "https://specs.apollo.dev/federation/v2.3" description = LINK_DIRECTIVE_DESCRIPTION, locations = [DirectiveLocation.SCHEMA] ) -annotation class LinkDirective(val url: String, val import: Array) +annotation class LinkDirective(val url: String, val `as`: String, val import: Array) internal const val LINK_DIRECTIVE_NAME = "link" private const val LINK_DIRECTIVE_DESCRIPTION = "Links definitions within the document to external schemas." -internal val LINK_DIRECTIVE_TYPE: graphql.schema.GraphQLDirective = graphql.schema.GraphQLDirective.newDirective() +internal fun linkDirectiveDefinition(importScalarType: GraphQLScalarType): graphql.schema.GraphQLDirective = graphql.schema.GraphQLDirective.newDirective() .name(LINK_DIRECTIVE_NAME) .description(LINK_DIRECTIVE_DESCRIPTION) .validLocations(DirectiveLocation.SCHEMA) @@ -65,18 +75,23 @@ internal val LINK_DIRECTIVE_TYPE: graphql.schema.GraphQLDirective = graphql.sche .name("url") .type(GraphQLNonNull.nonNull(Scalars.GraphQLString)) ) + .argument( + GraphQLArgument.newArgument() + .name("as") + .type(Scalars.GraphQLString) + ) .argument( GraphQLArgument .newArgument() .name("import") - .type(GraphQLList.list(Scalars.GraphQLString)) + .type(GraphQLList.list(importScalarType)) ) .repeatable(true) .build() -internal fun appliedLinkDirective(url: String, imports: List = emptyList()) = LINK_DIRECTIVE_TYPE.toAppliedDirective() +internal fun graphql.schema.GraphQLDirective.toAppliedLinkDirective(url: String, namespace: String? = null, imports: List = emptyList()) = this.toAppliedDirective() .transform { appliedDirectiveBuilder -> - LINK_DIRECTIVE_TYPE.getArgument("url") + this.getArgument("url") .toAppliedArgument() .transform { argumentBuilder -> argumentBuilder.valueProgrammatic(url) @@ -85,8 +100,19 @@ internal fun appliedLinkDirective(url: String, imports: List = emptyList appliedDirectiveBuilder.argument(it) } + if (!namespace.isNullOrBlank()) { + this.getArgument("as") + .toAppliedArgument() + .transform { argumentBuilder -> + argumentBuilder.valueProgrammatic(namespace) + } + .let { + appliedDirectiveBuilder.argument(it) + } + } + if (imports.isNotEmpty()) { - LINK_DIRECTIVE_TYPE.getArgument("import") + this.getArgument("import") .toAppliedArgument() .transform { argumentBuilder -> argumentBuilder.valueProgrammatic(imports) diff --git a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/LinkImport.kt b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/LinkImport.kt new file mode 100644 index 0000000000..da81d2278c --- /dev/null +++ b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/LinkImport.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2023 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.generator.federation.directives + +/** + * Annotation representing Import scalar type that is used to by @link directive to import types from a specification. + * + * When importing schema elements we can either: + * - import elements directly, i.e. use name that matches the type name in the imported specification, e.g. `@key` + * - specify custom name for imported elements (allows to avoid schema collisions), e.g. `{ name = "@key`, as = "@myKey"}` + * + * @param name original imported type name + * @param `as` imported type name in the schema + * + * @see [com.expediagroup.graphql.generator.federation.types.LINK_IMPORT_SCALAR_TYPE] + */ +@LinkedSpec(LINK_SPEC) +annotation class LinkImport(val name: String, val `as`: String = "") diff --git a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/LinkedSpec.kt b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/LinkedSpec.kt new file mode 100644 index 0000000000..d861606e80 --- /dev/null +++ b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/LinkedSpec.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2023 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.generator.federation.directives + +/** + * Meta annotation used to indicate that given directive/type is associated with imported `@link` specification. + * + * Example usage: + * ``` + * @LinkedSpec(FEDERATION_SPEC) + * @Repeatable + * @GraphQLDirective( + * name = KEY_DIRECTIVE_NAME, + * description = KEY_DIRECTIVE_DESCRIPTION, + * locations = [DirectiveLocation.OBJECT, DirectiveLocation.INTERFACE] + * ) + * annotation class KeyDirective(val fields: FieldSet, val resolvable: Boolean = true) + * ``` + */ +@Target(allowedTargets = [AnnotationTarget.ANNOTATION_CLASS, AnnotationTarget.CLASS]) +annotation class LinkedSpec(val value: String) diff --git a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/OverrideDirective.kt b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/OverrideDirective.kt index b3fd17e1df..e6a8d96c0d 100644 --- a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/OverrideDirective.kt +++ b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/OverrideDirective.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 Expedia, Inc + * Copyright 2023 Expedia, Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,10 +17,7 @@ package com.expediagroup.graphql.generator.federation.directives import com.expediagroup.graphql.generator.annotations.GraphQLDirective -import graphql.Scalars import graphql.introspection.Introspection.DirectiveLocation -import graphql.schema.GraphQLArgument -import graphql.schema.GraphQLNonNull /** * ```graphql @@ -36,6 +33,7 @@ import graphql.schema.GraphQLNonNull * * @see Publishing schema to Apollo Studio */ +@LinkedSpec(FEDERATION_SPEC) @GraphQLDirective( name = OVERRIDE_DIRECTIVE_NAME, description = OVERRIDE_DIRECTIVE_DESCRIPTION, @@ -45,14 +43,3 @@ annotation class OverrideDirective(val from: String) internal const val OVERRIDE_DIRECTIVE_NAME = "override" private const val OVERRIDE_DIRECTIVE_DESCRIPTION = "Overrides fields resolution logic from other subgraph. Used for migrating fields from one subgraph to another." - -internal val OVERRIDE_DIRECTIVE_TYPE: graphql.schema.GraphQLDirective = graphql.schema.GraphQLDirective.newDirective() - .name(OVERRIDE_DIRECTIVE_NAME) - .description(OVERRIDE_DIRECTIVE_DESCRIPTION) - .validLocations(DirectiveLocation.FIELD_DEFINITION) - .argument( - GraphQLArgument.newArgument() - .name("from") - .type(GraphQLNonNull.nonNull(Scalars.GraphQLString)) - ) - .build() diff --git a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/ProvidesDirective.kt b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/ProvidesDirective.kt index 4a395e4fdf..4eee1d5a1d 100644 --- a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/ProvidesDirective.kt +++ b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/ProvidesDirective.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 Expedia, Inc + * Copyright 2023 Expedia, Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,7 +18,9 @@ package com.expediagroup.graphql.generator.federation.directives import com.expediagroup.graphql.generator.annotations.GraphQLDirective import com.expediagroup.graphql.generator.federation.types.FIELD_SET_ARGUMENT +import com.expediagroup.graphql.generator.federation.types.fieldSetArgumentDefinition import graphql.introspection.Introspection.DirectiveLocation +import graphql.schema.GraphQLScalarType /** * ```graphql @@ -87,6 +89,7 @@ import graphql.introspection.Introspection.DirectiveLocation * @see FieldSet * @see ExternalDirective */ +@LinkedSpec(FEDERATION_SPEC) @GraphQLDirective( name = PROVIDES_DIRECTIVE_NAME, description = PROVIDES_DIRECTIVE_DESCRIPTION, @@ -103,3 +106,10 @@ internal val PROVIDES_DIRECTIVE_TYPE: graphql.schema.GraphQLDirective = graphql. .validLocations(DirectiveLocation.FIELD_DEFINITION) .argument(FIELD_SET_ARGUMENT) .build() + +internal fun providesDirectiveDefinition(fieldSetScalar: GraphQLScalarType): graphql.schema.GraphQLDirective = graphql.schema.GraphQLDirective.newDirective() + .name(PROVIDES_DIRECTIVE_NAME) + .description(PROVIDES_DIRECTIVE_DESCRIPTION) + .validLocations(DirectiveLocation.FIELD_DEFINITION) + .argument(fieldSetArgumentDefinition(fieldSetScalar)) + .build() diff --git a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/RequiresDirective.kt b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/RequiresDirective.kt index eb0e608325..a5a52d3392 100644 --- a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/RequiresDirective.kt +++ b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/RequiresDirective.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 Expedia, Inc + * Copyright 2023 Expedia, Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,7 +18,9 @@ package com.expediagroup.graphql.generator.federation.directives import com.expediagroup.graphql.generator.annotations.GraphQLDirective import com.expediagroup.graphql.generator.federation.types.FIELD_SET_ARGUMENT +import com.expediagroup.graphql.generator.federation.types.fieldSetArgumentDefinition import graphql.introspection.Introspection.DirectiveLocation +import graphql.schema.GraphQLScalarType /** * ```graphql @@ -69,6 +71,7 @@ import graphql.introspection.Introspection.DirectiveLocation * @see FieldSet * @see ExternalDirective */ +@LinkedSpec(FEDERATION_SPEC) @GraphQLDirective( name = REQUIRES_DIRECTIVE_NAME, description = REQUIRES_DIRECTIVE_DESCRIPTION, @@ -85,3 +88,10 @@ internal val REQUIRES_DIRECTIVE_TYPE: graphql.schema.GraphQLDirective = graphql. .validLocations(DirectiveLocation.FIELD_DEFINITION) .argument(FIELD_SET_ARGUMENT) .build() + +internal fun requiresDirectiveDefinition(fieldSetScalar: GraphQLScalarType): graphql.schema.GraphQLDirective = graphql.schema.GraphQLDirective.newDirective() + .name(REQUIRES_DIRECTIVE_NAME) + .description(REQUIRES_DIRECTIVE_DESCRIPTION) + .validLocations(DirectiveLocation.FIELD_DEFINITION) + .argument(fieldSetArgumentDefinition(fieldSetScalar)) + .build() diff --git a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/ShareableDirective.kt b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/ShareableDirective.kt index 68c8e6f762..f5868c6d95 100644 --- a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/ShareableDirective.kt +++ b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/ShareableDirective.kt @@ -44,6 +44,7 @@ import graphql.introspection.Introspection.DirectiveLocation * } * ``` */ +@LinkedSpec(FEDERATION_SPEC) @Repeatable @GraphQLDirective( name = SHAREABLE_DIRECTIVE_NAME, @@ -54,10 +55,3 @@ annotation class ShareableDirective internal const val SHAREABLE_DIRECTIVE_NAME = "shareable" private const val SHAREABLE_DIRECTIVE_DESCRIPTION = "Indicates that given object and/or field can be resolved by multiple subgraphs" - -internal val SHAREABLE_DIRECTIVE_TYPE: graphql.schema.GraphQLDirective = graphql.schema.GraphQLDirective.newDirective() - .name(SHAREABLE_DIRECTIVE_NAME) - .description(SHAREABLE_DIRECTIVE_DESCRIPTION) - .validLocations(DirectiveLocation.FIELD_DEFINITION, DirectiveLocation.OBJECT) - .repeatable(true) - .build() diff --git a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/TagDirective.kt b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/TagDirective.kt index 2d4700a239..8183e011d3 100644 --- a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/TagDirective.kt +++ b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/directives/TagDirective.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 Expedia, Inc + * Copyright 2023 Expedia, Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,10 +17,7 @@ package com.expediagroup.graphql.generator.federation.directives import com.expediagroup.graphql.generator.annotations.GraphQLDirective -import graphql.Scalars import graphql.introspection.Introspection.DirectiveLocation -import graphql.schema.GraphQLArgument -import graphql.schema.GraphQLNonNull /** * ```graphql @@ -41,6 +38,7 @@ import graphql.schema.GraphQLNonNull * @see Apollo Contracts * @see @tag specification */ +@LinkedSpec(FEDERATION_SPEC) @Repeatable @GraphQLDirective( name = TAG_DIRECTIVE_NAME, @@ -65,26 +63,3 @@ annotation class TagDirective( internal const val TAG_DIRECTIVE_NAME = "tag" private const val TAG_DIRECTIVE_DESCRIPTION = "Allows users to annotate fields and types with additional metadata information" - -internal val TAG_DIRECTIVE_TYPE: graphql.schema.GraphQLDirective = graphql.schema.GraphQLDirective.newDirective() - .name(TAG_DIRECTIVE_NAME) - .description(TAG_DIRECTIVE_DESCRIPTION) - .validLocations( - DirectiveLocation.FIELD_DEFINITION, - DirectiveLocation.OBJECT, - DirectiveLocation.INTERFACE, - DirectiveLocation.UNION, - DirectiveLocation.ARGUMENT_DEFINITION, - DirectiveLocation.SCALAR, - DirectiveLocation.ENUM, - DirectiveLocation.ENUM_VALUE, - DirectiveLocation.INPUT_OBJECT, - DirectiveLocation.INPUT_FIELD_DEFINITION - ) - .argument( - GraphQLArgument.newArgument() - .name("name") - .type(GraphQLNonNull(Scalars.GraphQLString)) - ) - .repeatable(true) - .build() diff --git a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/exception/DuplicateSpecificationLinkImport.kt b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/exception/DuplicateSpecificationLinkImport.kt new file mode 100644 index 0000000000..d3af08ceaa --- /dev/null +++ b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/exception/DuplicateSpecificationLinkImport.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2023 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.generator.federation.exception + +import com.expediagroup.graphql.generator.exceptions.GraphQLKotlinException + +/** + * Exception thrown if same specification is imported multiple times + */ +class DuplicateSpecificationLinkImport(spec: String, url: String) : GraphQLKotlinException( + message = "Invalid federated schema - multiple $spec specification @link(url: \"$url\") imports" +) diff --git a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/exception/UnknownSpecificationException.kt b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/exception/UnknownSpecificationException.kt new file mode 100644 index 0000000000..ff51965bd4 --- /dev/null +++ b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/exception/UnknownSpecificationException.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2023 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.generator.federation.exception + +import com.expediagroup.graphql.generator.exceptions.GraphQLKotlinException + +/** + * Exception thrown when trying to use a type from external specification without importing it through @link directive. + */ +class UnknownSpecificationException(name: String, specification: String) : GraphQLKotlinException( + message = "Attempting to use directive @$name from $specification specification without importing the spec through @link directive" +) diff --git a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/types/FieldSet.kt b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/types/FieldSet.kt index dbf060525a..c6366bccf5 100644 --- a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/types/FieldSet.kt +++ b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/types/FieldSet.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 Expedia, Inc + * Copyright 2023 Expedia, Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,7 +16,6 @@ package com.expediagroup.graphql.generator.federation.types -import com.apollographql.federation.graphqljava._FieldSet import com.expediagroup.graphql.generator.federation.directives.FieldSet import com.expediagroup.graphql.generator.federation.exception.CoercingValueToLiteralException import graphql.GraphQLContext @@ -50,11 +49,16 @@ internal val FIELD_SET_SCALAR_TYPE: GraphQLScalarType = GraphQLScalarType.newSca .coercing(FieldSetCoercing) .build() -internal val FIELD_SET_ARGUMENT = GraphQLArgument.newArgument() +internal val FIELD_SET_ARGUMENT: GraphQLArgument = GraphQLArgument.newArgument() .name(FIELD_SET_ARGUMENT_NAME) .type(GraphQLNonNull(FIELD_SET_SCALAR_TYPE)) .build() +internal fun fieldSetArgumentDefinition(fieldSetScalar: GraphQLScalarType): GraphQLArgument = GraphQLArgument.newArgument() + .name(FIELD_SET_ARGUMENT_NAME) + .type(GraphQLNonNull(fieldSetScalar)) + .build() + private object FieldSetCoercing : Coercing { override fun serialize(dataFetcherResult: Any, graphQLContext: GraphQLContext, locale: Locale): String = when (dataFetcherResult) { @@ -84,7 +88,7 @@ private object FieldSetCoercing : Coercing { override fun valueToLiteral(input: Any, graphQLContext: GraphQLContext, locale: Locale): Value<*> = when (input) { is FieldSet -> StringValue.newStringValue(input.value).build() - else -> throw CoercingValueToLiteralException(_FieldSet::class, input) + else -> throw CoercingValueToLiteralException(FieldSet::class, input) } } diff --git a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/types/LinkImport.kt b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/types/LinkImport.kt new file mode 100644 index 0000000000..f24c7c170a --- /dev/null +++ b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/types/LinkImport.kt @@ -0,0 +1,103 @@ +/* + * Copyright 2023 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.generator.federation.types + +import com.expediagroup.graphql.generator.federation.directives.LinkImport +import com.expediagroup.graphql.generator.federation.exception.CoercingValueToLiteralException +import graphql.GraphQLContext +import graphql.execution.CoercedVariables +import graphql.language.ObjectField +import graphql.language.ObjectValue +import graphql.language.StringValue +import graphql.language.Value +import graphql.schema.Coercing +import graphql.schema.CoercingParseLiteralException +import graphql.schema.CoercingParseValueException +import graphql.schema.CoercingSerializeException +import graphql.schema.GraphQLScalarType +import java.util.Locale + +/** + * Custom scalar that is used to represent references to elements from imported specification. + * + * Imported elements can either specify the same name as in the specification OR a local custom name (i.e. rename). + * * "@key" - simple import, using the same name as in the specification + * * { "name": "@key", "as": "@myKey" } - imports `@key` from the specification with a local `@myKey` name + */ +internal val LINK_IMPORT_SCALAR_TYPE: GraphQLScalarType = GraphQLScalarType.newScalar() + .name("Import") + .coercing(LinkImportCoercing()) + .build() + +private class LinkImportCoercing : Coercing { + override fun serialize(dataFetcherResult: Any, graphQLContext: GraphQLContext, locale: Locale): Any = when (dataFetcherResult) { + is LinkImport -> { + if (dataFetcherResult.`as`.isBlank() || dataFetcherResult.name == dataFetcherResult.`as`) { + dataFetcherResult.name + } else { + mapOf("name" to dataFetcherResult.name, "as" to dataFetcherResult.`as`) + } + } + else -> throw CoercingSerializeException( + "Cannot serialize $dataFetcherResult. Expected type `LinkImport` but was ${dataFetcherResult.javaClass.simpleName}." + ) + } + + override fun parseValue(input: Any, graphQLContext: GraphQLContext, locale: Locale): LinkImport = when (input) { + is LinkImport -> input + is StringValue -> LinkImport(name = input.value, `as` = input.value) + is ObjectValue -> { + val nameValue = input.objectFields.firstOrNull { it.name == "name" }?.value as? StringValue ?: throw CoercingParseValueException("Cannot parse $input to LinkImport") + val namespacedValue = input.objectFields.firstOrNull { it.name == "as" }?.value as? StringValue + LinkImport(name = nameValue.value, `as` = namespacedValue?.value ?: nameValue.value) + } + else -> throw CoercingParseValueException( + "Cannot parse $input to LinkImport. Expected AST type of `StringValue` or `ObjectValue` but was ${input.javaClass.simpleName} " + ) + } + + override fun parseLiteral(input: Value<*>, variables: CoercedVariables, graphQLContext: GraphQLContext, locale: Locale): LinkImport = + when (input) { + is StringValue -> LinkImport(name = input.value, `as` = input.value) + is ObjectValue -> { + val nameValue = input.objectFields.firstOrNull { it.name == "name" }?.value as? StringValue ?: throw CoercingParseLiteralException("Cannot parse $input to LinkImport") + val namespacedValue = input.objectFields.firstOrNull { it.name == "as" }?.value as? StringValue + LinkImport(name = nameValue.value, `as` = namespacedValue?.value ?: nameValue.value) + } + else -> throw CoercingParseLiteralException( + "Cannot parse $input to LinkImport. Expected AST type of `StringValue` or `ObjectValue` but was ${input.javaClass.simpleName} " + ) + } + + override fun valueToLiteral(input: Any, graphQLContext: GraphQLContext, locale: Locale): Value<*> { + return when (input) { + is String -> StringValue.newStringValue(input).build() + is LinkImport -> { + val nameValue = StringValue.newStringValue(input.name).build() + if (input.`as`.isBlank() || input.name == input.`as`) { + nameValue + } else { + ObjectValue.newObjectValue() + .objectField(ObjectField("name", nameValue)) + .objectField(ObjectField("as", StringValue.newStringValue(input.`as`).build())) + .build() + } + } + else -> throw CoercingValueToLiteralException(LinkImport::class, input) + } + } +} diff --git a/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/FederatedSchemaV2GeneratorTest.kt b/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/FederatedSchemaV2GeneratorTest.kt index 21c5450416..4bde92131e 100644 --- a/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/FederatedSchemaV2GeneratorTest.kt +++ b/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/FederatedSchemaV2GeneratorTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 Expedia, Inc + * Copyright 2023 Expedia, Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -30,13 +30,10 @@ class FederatedSchemaV2GeneratorTest { fun `verify can generate federated schema`() { val expectedSchema = """ - schema @link(import : ["@composeDirective", "@extends", "@external", "@inaccessible", "@interfaceObject", "@key", "@override", "@provides", "@requires", "@shareable", "@tag", "FieldSet"], url : "https://specs.apollo.dev/federation/v2.3"){ + schema @link(import : ["@external", "@key", "@provides", "@requires", "FieldSet"], url : "https://specs.apollo.dev/federation/v2.3"){ query: Query } - "Marks underlying custom directive to be included in the Supergraph schema" - directive @composeDirective(name: String!) repeatable on SCHEMA - directive @custom on SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION "Marks the field, argument, input field or enum value as deprecated" @@ -45,32 +42,20 @@ class FederatedSchemaV2GeneratorTest { reason: String = "No longer supported" ) on FIELD_DEFINITION | ARGUMENT_DEFINITION | ENUM_VALUE | INPUT_FIELD_DEFINITION - "Marks target object as extending part of the federated schema" - directive @extends on OBJECT | INTERFACE - "Marks target field as external meaning it will be resolved by federated schema" directive @external on OBJECT | FIELD_DEFINITION - "Marks location within schema as inaccessible from the GraphQL Gateway" - directive @inaccessible on SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION - "Directs the executor to include this field or fragment only when the `if` argument is true" directive @include( "Included when true." if: Boolean! ) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT - "Provides meta information to the router that this entity type is an interface in the supergraph." - directive @interfaceObject on OBJECT - "Space separated list of primary keys needed to access federated object" directive @key(fields: FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE "Links definitions within the document to external schemas." - directive @link(import: [String], url: String!) repeatable on SCHEMA - - "Overrides fields resolution logic from other subgraph. Used for migrating fields from one subgraph to another." - directive @override(from: String!) on FIELD_DEFINITION + directive @link(as: String, import: [link__Import], url: String!) repeatable on SCHEMA "Specifies the base type field set that will be selectable by the gateway" directive @provides(fields: FieldSet!) on FIELD_DEFINITION @@ -78,9 +63,6 @@ class FederatedSchemaV2GeneratorTest { "Specifies required input field set from the base type for a resolver" directive @requires(fields: FieldSet!) on FIELD_DEFINITION - "Indicates that given object and/or field can be resolved by multiple subgraphs" - directive @shareable repeatable on OBJECT | FIELD_DEFINITION - "Directs the executor to skip this field or fragment when the `if` argument is true." directive @skip( "Skipped when true." @@ -93,9 +75,6 @@ class FederatedSchemaV2GeneratorTest { url: String! ) on SCALAR - "Allows users to annotate fields and types with additional metadata information" - directive @tag(name: String!) repeatable on SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION - interface Product @key(fields : "id", resolvable : true) @key(fields : "upc", resolvable : true) { id: String! reviews: [Review!]! @@ -149,6 +128,8 @@ class FederatedSchemaV2GeneratorTest { "Federation scalar type used to represent any external entities passed to _entities query." scalar _Any + + scalar link__Import """.trimIndent() val config = FederatedSchemaGeneratorConfig( diff --git a/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/directives/compose/ComposeDirectiveTest.kt b/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/directives/compose/ComposeDirectiveTest.kt new file mode 100644 index 0000000000..8b536ddcd1 --- /dev/null +++ b/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/directives/compose/ComposeDirectiveTest.kt @@ -0,0 +1,146 @@ +/* + * Copyright 2023 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.generator.federation.directives.compose + +import com.expediagroup.graphql.generator.TopLevelObject +import com.expediagroup.graphql.generator.annotations.GraphQLDirective +import com.expediagroup.graphql.generator.extensions.print +import com.expediagroup.graphql.generator.federation.FederatedSchemaGeneratorConfig +import com.expediagroup.graphql.generator.federation.FederatedSchemaGeneratorHooks +import com.expediagroup.graphql.generator.federation.directives.ComposeDirective +import com.expediagroup.graphql.generator.federation.directives.FieldSet +import com.expediagroup.graphql.generator.federation.directives.KEY_DIRECTIVE_NAME +import com.expediagroup.graphql.generator.federation.directives.KeyDirective +import com.expediagroup.graphql.generator.federation.directives.LinkDirective +import com.expediagroup.graphql.generator.federation.directives.LinkImport +import com.expediagroup.graphql.generator.federation.directives.LinkedSpec +import com.expediagroup.graphql.generator.federation.toFederatedSchema +import com.expediagroup.graphql.generator.federation.types.ENTITY_UNION_NAME +import com.expediagroup.graphql.generator.scalars.ID +import graphql.introspection.Introspection +import graphql.schema.GraphQLUnionType +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class ComposeDirectiveTest { + + @Test + fun `verify we can generate valid schema with @composeDirective`() { + val expectedSchema = + """ + schema @composeDirective(name : "custom") @link(as : "myspec", import : ["@custom"], url : "https://www.myspecs.dev/myspec/v1.0") @link(import : ["@composeDirective", "@key", "FieldSet"], url : "https://specs.apollo.dev/federation/v2.3"){ + query: Query + } + + "Marks underlying custom directive to be included in the Supergraph schema" + directive @composeDirective(name: String!) repeatable on SCHEMA + + directive @custom on FIELD_DEFINITION + + "Marks the field, argument, input field or enum value as deprecated" + directive @deprecated( + "The reason for the deprecation" + reason: String = "No longer supported" + ) on FIELD_DEFINITION | ARGUMENT_DEFINITION | ENUM_VALUE | INPUT_FIELD_DEFINITION + + "Directs the executor to include this field or fragment only when the `if` argument is true" + directive @include( + "Included when true." + if: Boolean! + ) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT + + "Space separated list of primary keys needed to access federated object" + directive @key(fields: FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE + + "Links definitions within the document to external schemas." + directive @link(as: String, import: [link__Import], url: String!) repeatable on SCHEMA + + "Directs the executor to skip this field or fragment when the `if` argument is true." + directive @skip( + "Skipped when true." + if: Boolean! + ) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT + + "Exposes a URL that specifies the behaviour of this scalar." + directive @specifiedBy( + "The URL that specifies the behaviour of this scalar." + url: String! + ) on SCALAR + + union _Entity = Foo + + type Foo @key(fields : "id", resolvable : true) { + id: ID! + name: String + } + + type Query { + "Union of all types that use the @key directive, including both types native to the schema and extended types" + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! + foo: Foo! @custom + } + + type _Service { + sdl: String! + } + + "Federation type representing set of fields" + scalar FieldSet + + "Federation scalar type used to represent any external entities passed to _entities query." + scalar _Any + + scalar link__Import + """.trimIndent() + + val config = FederatedSchemaGeneratorConfig( + supportedPackages = listOf("com.expediagroup.graphql.generator.federation.directives.compose"), + hooks = FederatedSchemaGeneratorHooks(emptyList()) + ) + + val schema = toFederatedSchema(queries = listOf(TopLevelObject(FooQuery())), schemaObject = TopLevelObject(CustomSchema()), config = config) + Assertions.assertEquals(expectedSchema, schema.print().trim()) + val fooType = schema.getObjectType("Foo") + assertNotNull(fooType) + assertNotNull(fooType.hasAppliedDirective(KEY_DIRECTIVE_NAME)) + + val entityUnion = schema.getType(ENTITY_UNION_NAME) as? GraphQLUnionType + assertNotNull(entityUnion) + assertTrue(entityUnion.types.contains(fooType)) + } + + @LinkDirective(url = "https://www.myspecs.dev/myspec/v1.0", `as` = "myspec", import = [LinkImport("@custom")]) + @ComposeDirective(name = "custom") + class CustomSchema + + @KeyDirective(fields = FieldSet("id")) + data class Foo(val id: ID, val name: String?) + + @LinkedSpec("myspec") + @GraphQLDirective( + name = "custom", + locations = [Introspection.DirectiveLocation.FIELD_DEFINITION] + ) + annotation class CustomDirective + + class FooQuery { + @CustomDirective + fun foo(): Foo = TODO() + } +} diff --git a/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/directives/contact/ContactDirectiveTest.kt b/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/directives/contact/ContactDirectiveTest.kt new file mode 100644 index 0000000000..0e8fb6e510 --- /dev/null +++ b/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/directives/contact/ContactDirectiveTest.kt @@ -0,0 +1,98 @@ +/* + * Copyright 2023 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.generator.federation.directives.contact + +import com.expediagroup.graphql.generator.TopLevelObject +import com.expediagroup.graphql.generator.extensions.print +import com.expediagroup.graphql.generator.federation.FederatedSchemaGeneratorConfig +import com.expediagroup.graphql.generator.federation.FederatedSchemaGeneratorHooks +import com.expediagroup.graphql.generator.federation.directives.ContactDirective +import com.expediagroup.graphql.generator.federation.toFederatedSchema +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class ContactDirectiveTest { + + @Test + fun `verify we can import federation spec using custom @link`() { + val expectedSchema = + """ + schema @contact(description : "Send emails to foo@myteamname.com", name : "My Team Name", url : "https://myteam.slack.com/room") @link(url : "https://specs.apollo.dev/federation/v2.3"){ + query: Query + } + + "Provides contact information of the owner responsible for this subgraph schema." + directive @contact(description: String, name: String!, url: String) on SCHEMA + + "Marks the field, argument, input field or enum value as deprecated" + directive @deprecated( + "The reason for the deprecation" + reason: String = "No longer supported" + ) on FIELD_DEFINITION | ARGUMENT_DEFINITION | ENUM_VALUE | INPUT_FIELD_DEFINITION + + "Directs the executor to include this field or fragment only when the `if` argument is true" + directive @include( + "Included when true." + if: Boolean! + ) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT + + "Links definitions within the document to external schemas." + directive @link(as: String, import: [link__Import], url: String!) repeatable on SCHEMA + + "Directs the executor to skip this field or fragment when the `if` argument is true." + directive @skip( + "Skipped when true." + if: Boolean! + ) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT + + "Exposes a URL that specifies the behaviour of this scalar." + directive @specifiedBy( + "The URL that specifies the behaviour of this scalar." + url: String! + ) on SCALAR + + type Query { + _service: _Service! + foo: String! + } + + type _Service { + sdl: String! + } + + scalar link__Import + """.trimIndent() + + val config = FederatedSchemaGeneratorConfig( + supportedPackages = listOf("com.expediagroup.graphql.generator.federation.directives.contact"), + hooks = FederatedSchemaGeneratorHooks(emptyList()) + ) + + val schema = toFederatedSchema( + queries = listOf(TopLevelObject(FooQuery())), + schemaObject = TopLevelObject(CustomSchema()), + config = config + ) + assertEquals(expectedSchema, schema.print().trim()) + } + + @ContactDirective(name = "My Team Name", url = "https://myteam.slack.com/room", description = "Send emails to foo@myteamname.com") + class CustomSchema + + class FooQuery { + fun foo(): String = TODO() + } +} diff --git a/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/directives/link/LinkDirectiveTest.kt b/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/directives/link/LinkDirectiveTest.kt new file mode 100644 index 0000000000..df60e19208 --- /dev/null +++ b/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/directives/link/LinkDirectiveTest.kt @@ -0,0 +1,166 @@ +/* + * Copyright 2023 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.generator.federation.directives.link + +import com.expediagroup.graphql.generator.TopLevelObject +import com.expediagroup.graphql.generator.annotations.GraphQLDirective +import com.expediagroup.graphql.generator.extensions.print +import com.expediagroup.graphql.generator.federation.FederatedSchemaGeneratorConfig +import com.expediagroup.graphql.generator.federation.FederatedSchemaGeneratorHooks +import com.expediagroup.graphql.generator.federation.directives.FEDERATION_SPEC_LATEST_URL +import com.expediagroup.graphql.generator.federation.directives.FieldSet +import com.expediagroup.graphql.generator.federation.directives.KEY_DIRECTIVE_NAME +import com.expediagroup.graphql.generator.federation.directives.KeyDirective +import com.expediagroup.graphql.generator.federation.directives.LinkDirective +import com.expediagroup.graphql.generator.federation.directives.LinkImport +import com.expediagroup.graphql.generator.federation.directives.LinkedSpec +import com.expediagroup.graphql.generator.federation.exception.DuplicateSpecificationLinkImport +import com.expediagroup.graphql.generator.federation.exception.UnknownSpecificationException +import com.expediagroup.graphql.generator.federation.toFederatedSchema +import com.expediagroup.graphql.generator.federation.types.ENTITY_UNION_NAME +import com.expediagroup.graphql.generator.scalars.ID +import graphql.schema.GraphQLUnionType +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class LinkDirectiveTest { + + @Test + fun `verify we can import federation spec using custom @link`() { + val expectedSchema = + """ + schema @link(as : "fed", import : [{name : "@key", as : "@myKey"}], url : "https://specs.apollo.dev/federation/v2.3"){ + query: Query + } + + "Marks the field, argument, input field or enum value as deprecated" + directive @deprecated( + "The reason for the deprecation" + reason: String = "No longer supported" + ) on FIELD_DEFINITION | ARGUMENT_DEFINITION | ENUM_VALUE | INPUT_FIELD_DEFINITION + + "Directs the executor to include this field or fragment only when the `if` argument is true" + directive @include( + "Included when true." + if: Boolean! + ) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT + + "Links definitions within the document to external schemas." + directive @link(as: String, import: [link__Import], url: String!) repeatable on SCHEMA + + "Space separated list of primary keys needed to access federated object" + directive @myKey(fields: fed__FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE + + "Directs the executor to skip this field or fragment when the `if` argument is true." + directive @skip( + "Skipped when true." + if: Boolean! + ) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT + + "Exposes a URL that specifies the behaviour of this scalar." + directive @specifiedBy( + "The URL that specifies the behaviour of this scalar." + url: String! + ) on SCALAR + + union _Entity = Foo + + type Foo @myKey(fields : "id", resolvable : true) { + id: ID! + name: String + } + + type Query { + "Union of all types that use the @key directive, including both types native to the schema and extended types" + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! + } + + type _Service { + sdl: String! + } + + "Federation scalar type used to represent any external entities passed to _entities query." + scalar _Any + + "Federation type representing set of fields" + scalar fed__FieldSet + + scalar link__Import + """.trimIndent() + + val config = FederatedSchemaGeneratorConfig( + supportedPackages = listOf("com.expediagroup.graphql.generator.federation.directives.link"), + hooks = FederatedSchemaGeneratorHooks(emptyList()) + ) + + val schema = toFederatedSchema(schemaObject = TopLevelObject(CustomSchema()), config = config) + Assertions.assertEquals(expectedSchema, schema.print().trim()) + val fooType = schema.getObjectType("Foo") + assertNotNull(fooType) + assertNotNull(fooType.hasAppliedDirective(KEY_DIRECTIVE_NAME)) + + val entityUnion = schema.getType(ENTITY_UNION_NAME) as? GraphQLUnionType + assertNotNull(entityUnion) + assertTrue(entityUnion.types.contains(fooType)) + } + + @Test + fun `verifies exception is thrown if we attempt to import same specification multiple times`() { + val config = FederatedSchemaGeneratorConfig( + supportedPackages = listOf("com.expediagroup.graphql.generator.federation.directives.link"), + hooks = FederatedSchemaGeneratorHooks(emptyList()) + ) + + assertThrows { + toFederatedSchema(schemaObject = TopLevelObject(MultipleSpecImportsSchema()), config = config) + } + } + + @Test + fun `verifies exception is thrown when attempting to use external type without importing it from specification`() { + val config = FederatedSchemaGeneratorConfig( + supportedPackages = listOf("com.expediagroup.graphql.generator.federation.directives.link"), + hooks = FederatedSchemaGeneratorHooks(emptyList()) + ) + + assertThrows { + toFederatedSchema(queries = listOf(TopLevelObject(FooQuery())), config = config) + } + } + + @LinkDirective(url = FEDERATION_SPEC_LATEST_URL, `as` = "fed", import = [LinkImport("@key", "@myKey")]) + class CustomSchema + + @LinkDirective(url = FEDERATION_SPEC_LATEST_URL, `as` = "fed", import = [LinkImport("@key", "@myKey")]) + @LinkDirective(url = FEDERATION_SPEC_LATEST_URL, `as` = "federation", import = [LinkImport("@key")]) + class MultipleSpecImportsSchema + + @KeyDirective(fields = FieldSet("id")) + data class Foo(val id: ID, val name: String?) + + @LinkedSpec("mySpec") + @GraphQLDirective + annotation class CustomDirective + + class FooQuery { + @CustomDirective + fun foo(): Foo = TODO() + } +} diff --git a/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/execution/ServiceQueryResolverTest.kt b/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/execution/ServiceQueryResolverTest.kt index 270f0f7879..2fd9b1b5c9 100644 --- a/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/execution/ServiceQueryResolverTest.kt +++ b/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/execution/ServiceQueryResolverTest.kt @@ -90,45 +90,12 @@ scalar CustomScalar""" const val BASE_SERVICE_SDL = """ -schema @link(import : ["@composeDirective", "@extends", "@external", "@inaccessible", "@interfaceObject", "@key", "@override", "@provides", "@requires", "@shareable", "@tag", "FieldSet"], url : "https://specs.apollo.dev/federation/v2.3"){ +schema @link(url : "https://specs.apollo.dev/federation/v2.3"){ query: Query } -"Marks underlying custom directive to be included in the Supergraph schema" -directive @composeDirective(name: String!) repeatable on SCHEMA - -"Marks target object as extending part of the federated schema" -directive @extends on OBJECT | INTERFACE - -"Marks target field as external meaning it will be resolved by federated schema" -directive @external on OBJECT | FIELD_DEFINITION - -"Marks location within schema as inaccessible from the GraphQL Gateway" -directive @inaccessible on SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION - -"Provides meta information to the router that this entity type is an interface in the supergraph." -directive @interfaceObject on OBJECT - -"Space separated list of primary keys needed to access federated object" -directive @key(fields: FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE - "Links definitions within the document to external schemas." -directive @link(import: [String], url: String!) repeatable on SCHEMA - -"Overrides fields resolution logic from other subgraph. Used for migrating fields from one subgraph to another." -directive @override(from: String!) on FIELD_DEFINITION - -"Specifies the base type field set that will be selectable by the gateway" -directive @provides(fields: FieldSet!) on FIELD_DEFINITION - -"Specifies required input field set from the base type for a resolver" -directive @requires(fields: FieldSet!) on FIELD_DEFINITION - -"Indicates that given object and/or field can be resolved by multiple subgraphs" -directive @shareable repeatable on OBJECT | FIELD_DEFINITION - -"Allows users to annotate fields and types with additional metadata information" -directive @tag(name: String!) repeatable on SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION +directive @link(as: String, import: [link__Import], url: String!) repeatable on SCHEMA type Query { _service: _Service! @@ -146,41 +113,25 @@ type _Service { sdl: String! } -"Federation type representing set of fields" -scalar FieldSet +scalar link__Import """ const val FEDERATED_SERVICE_SDL_V2 = """ -schema @link(import : ["@composeDirective", "@extends", "@external", "@inaccessible", "@interfaceObject", "@key", "@override", "@provides", "@requires", "@shareable", "@tag", "FieldSet"], url : "https://specs.apollo.dev/federation/v2.3"){ +schema @link(import : ["@external", "@key", "@provides", "@requires", "FieldSet"], url : "https://specs.apollo.dev/federation/v2.3"){ query: Query } -"Marks underlying custom directive to be included in the Supergraph schema" -directive @composeDirective(name: String!) repeatable on SCHEMA - directive @custom on SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION -"Marks target object as extending part of the federated schema" -directive @extends on OBJECT | INTERFACE - "Marks target field as external meaning it will be resolved by federated schema" directive @external on OBJECT | FIELD_DEFINITION -"Marks location within schema as inaccessible from the GraphQL Gateway" -directive @inaccessible on SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION - -"Provides meta information to the router that this entity type is an interface in the supergraph." -directive @interfaceObject on OBJECT - "Space separated list of primary keys needed to access federated object" directive @key(fields: FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE "Links definitions within the document to external schemas." -directive @link(import: [String], url: String!) repeatable on SCHEMA - -"Overrides fields resolution logic from other subgraph. Used for migrating fields from one subgraph to another." -directive @override(from: String!) on FIELD_DEFINITION +directive @link(as: String, import: [link__Import], url: String!) repeatable on SCHEMA "Specifies the base type field set that will be selectable by the gateway" directive @provides(fields: FieldSet!) on FIELD_DEFINITION @@ -188,12 +139,6 @@ directive @provides(fields: FieldSet!) on FIELD_DEFINITION "Specifies required input field set from the base type for a resolver" directive @requires(fields: FieldSet!) on FIELD_DEFINITION -"Indicates that given object and/or field can be resolved by multiple subgraphs" -directive @shareable repeatable on OBJECT | FIELD_DEFINITION - -"Allows users to annotate fields and types with additional metadata information" -directive @tag(name: String!) repeatable on SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION - interface Product @key(fields : "id", resolvable : true) @key(fields : "upc", resolvable : true) { id: String! reviews: [Review!]! @@ -247,6 +192,8 @@ scalar FieldSet "Federation scalar type used to represent any external entities passed to _entities query." scalar _Any + +scalar link__Import """ class ServiceQueryResolverTest { diff --git a/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/types/LinkImportTest.kt b/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/types/LinkImportTest.kt new file mode 100644 index 0000000000..d817ee7968 --- /dev/null +++ b/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/types/LinkImportTest.kt @@ -0,0 +1,162 @@ +/* + * Copyright 2023 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.generator.federation.types + +import com.expediagroup.graphql.generator.federation.directives.LinkImport +import com.expediagroup.graphql.generator.federation.exception.CoercingValueToLiteralException +import graphql.GraphQLContext +import graphql.execution.CoercedVariables +import graphql.language.IntValue +import graphql.language.ObjectField +import graphql.language.ObjectValue +import graphql.language.StringValue +import graphql.schema.Coercing +import graphql.schema.CoercingParseLiteralException +import graphql.schema.CoercingParseValueException +import graphql.schema.CoercingSerializeException +import org.junit.jupiter.api.Test +import java.math.BigInteger +import java.util.Locale +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class LinkImportTest { + private val coercing: Coercing<*, *> = LINK_IMPORT_SCALAR_TYPE.coercing + + @Test + fun `serialize should throw exception when not a LinkImport`() { + assertFailsWith { + coercing.serialize(StringValue("hello"), GraphQLContext.getDefault(), Locale.ENGLISH) + } + } + + @Test + fun `serialize should return the value when LinkImport`() { + val result = coercing.serialize(LinkImport(name = "@foo"), GraphQLContext.getDefault(), Locale.ENGLISH) + assertEquals(expected = "@foo", actual = result) + } + + @Test + fun `serialize should return the value when LinkImport with renames`() { + val result = coercing.serialize(LinkImport(name = "@foo", `as` = "@bar"), GraphQLContext.getDefault(), Locale.ENGLISH) + assertTrue(result is Map<*, *>) + assertEquals(expected = "@foo", actual = result["name"]) + assertEquals(expected = "@bar", actual = result["as"]) + } + + @Test + fun `parseValue should be able to parse StringValue`() { + val result = coercing.parseValue(StringValue("@foo"), GraphQLContext.getDefault(), Locale.ENGLISH) + assertTrue(result is LinkImport) + assertEquals("@foo", result.name) + } + + @Test + fun `parseValue should be able to parse ObjectValue`() { + val objectValue = ObjectValue( + listOf( + ObjectField("name", StringValue("@foo")), + ObjectField("as", StringValue("@bar")) + ) + ) + val result = coercing.parseValue(objectValue, GraphQLContext.getDefault(), Locale.ENGLISH) + assertTrue(result is LinkImport) + assertEquals("@foo", result.name) + assertEquals("@bar", result.`as`) + } + + @Test + fun `parseValue should throw exception on unhandled value`() { + assertFailsWith { + coercing.parseValue(IntValue(BigInteger.ONE), GraphQLContext.getDefault(), Locale.ENGLISH) + } + } + + @Test + fun `parseValue should throw exception on unknown ObjectValue`() { + assertFailsWith { + coercing.parseValue(ObjectValue(listOf(ObjectField("foo", StringValue("FOO")))), GraphQLContext.getDefault(), Locale.ENGLISH) + } + } + + @Test + fun `parseLiteral should map StringValue to a LinkImport`() { + val result = coercing.parseLiteral(StringValue("@foo"), CoercedVariables.emptyVariables(), GraphQLContext.getDefault(), Locale.ENGLISH) + assertTrue(result is LinkImport) + assertEquals("@foo", result.name) + } + + @Test + fun `parseLiteral should be able to parse ObjectValue`() { + val objectValue = ObjectValue( + listOf( + ObjectField("name", StringValue("@foo")), + ObjectField("as", StringValue("@bar")) + ) + ) + val result = coercing.parseLiteral(objectValue, CoercedVariables.emptyVariables(), GraphQLContext.getDefault(), Locale.ENGLISH) + assertTrue(result is LinkImport) + assertEquals("@foo", result.name) + assertEquals("@bar", result.`as`) + } + + @Test + fun `parseLiteral should throw exception on unhandled value`() { + assertFailsWith { + coercing.parseLiteral(IntValue(BigInteger.ONE), CoercedVariables.emptyVariables(), GraphQLContext.getDefault(), Locale.ENGLISH) + } + } + + @Test + fun `parseLiteral should throw exception on unknown ObjectValue`() { + assertFailsWith { + coercing.parseLiteral(ObjectValue(listOf(ObjectField("foo", StringValue("FOO")))), CoercedVariables.emptyVariables(), GraphQLContext.getDefault(), Locale.ENGLISH) + } + } + + @Test + fun `valueToLiteral should map simple string import to StringValue`() { + val result = coercing.valueToLiteral("@foo", GraphQLContext.getDefault(), Locale.ENGLISH) + assertTrue(result is StringValue) + assertEquals("@foo", result.value) + } + + @Test + fun `valueToLiteral should map simple LinkImport to StringValue`() { + val result = coercing.valueToLiteral(LinkImport(name = "@foo"), GraphQLContext.getDefault(), Locale.ENGLISH) + assertTrue(result is StringValue) + assertEquals("@foo", result.value) + } + + @Test + fun `valueToLiteral should map LinkImport to ObjectValue`() { + val result = coercing.valueToLiteral(LinkImport(name = "@foo", `as` = "@bar"), GraphQLContext.getDefault(), Locale.ENGLISH) + assertTrue(result is ObjectValue) + val nameFieldValue = result.objectFields.find { it.name == "name" }?.value as? StringValue + val namespaceFieldValue = result.objectFields.find { it.name == "as" }?.value as? StringValue + assertEquals("@foo", nameFieldValue?.value) + assertEquals("@bar", namespaceFieldValue?.value) + } + + @Test + fun `valueToLiteral should throw exception on unhandled value`() { + assertFailsWith { + coercing.valueToLiteral(123, GraphQLContext.getDefault(), Locale.ENGLISH) + } + } +} diff --git a/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/validation/FederatedSchemaValidatorTest.kt b/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/validation/FederatedSchemaValidatorTest.kt index eb2e967963..81fd952cea 100644 --- a/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/validation/FederatedSchemaValidatorTest.kt +++ b/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/validation/FederatedSchemaValidatorTest.kt @@ -18,13 +18,14 @@ package com.expediagroup.graphql.generator.federation.validation import com.expediagroup.graphql.generator.federation.directives.EXTENDS_DIRECTIVE_TYPE import com.expediagroup.graphql.generator.federation.directives.KEY_DIRECTIVE_NAME -import com.expediagroup.graphql.generator.federation.directives.KEY_DIRECTIVE_TYPE_V2 import com.expediagroup.graphql.generator.federation.directives.PROVIDES_DIRECTIVE_NAME import com.expediagroup.graphql.generator.federation.directives.REQUIRES_DIRECTIVE_NAME +import com.expediagroup.graphql.generator.federation.directives.keyDirectiveDefinition import com.expediagroup.graphql.generator.federation.exception.InvalidFederatedSchema import com.expediagroup.graphql.generator.federation.externalDirective import com.expediagroup.graphql.generator.federation.getKeyDirective import com.expediagroup.graphql.generator.federation.types.FIELD_SET_ARGUMENT +import com.expediagroup.graphql.generator.federation.types.FIELD_SET_SCALAR_TYPE import graphql.Scalars.GraphQLString import graphql.schema.GraphQLAppliedDirective import graphql.schema.GraphQLFieldDefinition @@ -76,7 +77,7 @@ class FederatedSchemaValidatorTest { val typeToValidate = GraphQLObjectType.newObject() .name("Foo") .withAppliedDirective( - KEY_DIRECTIVE_TYPE_V2.toAppliedDirective() + keyDirectiveDefinition(FIELD_SET_SCALAR_TYPE).toAppliedDirective() .transform { directive -> directive.argument( FIELD_SET_ARGUMENT.toAppliedArgument() diff --git a/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/validation/integration/ComposeDirectiveIT.kt b/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/validation/integration/ComposeDirectiveIT.kt index 646f296ffb..e4485d12a6 100644 --- a/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/validation/integration/ComposeDirectiveIT.kt +++ b/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/validation/integration/ComposeDirectiveIT.kt @@ -20,49 +20,48 @@ import com.expediagroup.graphql.generator.TopLevelObject import com.expediagroup.graphql.generator.extensions.print import com.expediagroup.graphql.generator.federation.data.integration.composeDirective.CustomSchema import com.expediagroup.graphql.generator.federation.data.integration.composeDirective.SimpleQuery +import com.expediagroup.graphql.generator.federation.directives.FEDERATION_SPEC_LATEST_VERSION import com.expediagroup.graphql.generator.federation.toFederatedSchema -import org.junit.jupiter.api.Assertions +import kotlin.test.assertEquals import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertDoesNotThrow class ComposeDirectiveIT { @Test fun `verifies applying @composeDirective generates valid schema`() { - assertDoesNotThrow { - val schema = toFederatedSchema( - config = federatedTestConfig("com.expediagroup.graphql.generator.federation.data.integration.composeDirective"), - queries = listOf(TopLevelObject(SimpleQuery())), - schemaObject = TopLevelObject(CustomSchema()) - ) + val schema = toFederatedSchema( + config = federatedTestConfig("com.expediagroup.graphql.generator.federation.data.integration.composeDirective"), + queries = listOf(TopLevelObject(SimpleQuery())), + schemaObject = TopLevelObject(CustomSchema()) + ) - val expected = """ - schema @composeDirective(name : "custom") @link(import : ["@composeDirective", "@extends", "@external", "@inaccessible", "@interfaceObject", "@key", "@override", "@provides", "@requires", "@shareable", "@tag", "FieldSet"], url : "https://specs.apollo.dev/federation/v2.3"){ - query: Query - } + val expected = """ + schema @composeDirective(name : "custom") @link(import : ["@composeDirective"], url : "https://specs.apollo.dev/federation/v$FEDERATION_SPEC_LATEST_VERSION"){ + query: Query + } - "Marks underlying custom directive to be included in the Supergraph schema" - directive @composeDirective(name: String!) repeatable on SCHEMA + "Marks underlying custom directive to be included in the Supergraph schema" + directive @composeDirective(name: String!) repeatable on SCHEMA - directive @custom on FIELD_DEFINITION + directive @custom on FIELD_DEFINITION - "Links definitions within the document to external schemas." - directive @link(import: [String], url: String!) repeatable on SCHEMA + "Links definitions within the document to external schemas." + directive @link(as: String, import: [link__Import], url: String!) repeatable on SCHEMA - type Query { - _service: _Service! - helloWorld: String! @custom - } + type Query { + _service: _Service! + helloWorld: String! @custom + } - type _Service { - sdl: String! - } - """.trimIndent() - val actual = schema.print( - includeDirectivesFilter = { directive -> "link" == directive || "composeDirective" == directive || "custom" == directive }, - includeScalarTypes = false - ).trim() - Assertions.assertEquals(expected, actual) - } + type _Service { + sdl: String! + } + + scalar link__Import + """.trimIndent() + val actual = schema.print( + includeDirectivesFilter = { directive -> "link" == directive || "composeDirective" == directive || "custom" == directive }, + ).trim() + assertEquals(expected, actual) } } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index c1962a79e2..033e24c4cd 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 37aef8d3f0..9f4197d5f4 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-bin.zip networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index aeb74cbb43..fcb6fca147 100755 --- a/gradlew +++ b/gradlew @@ -130,10 +130,13 @@ location of your Java installation." fi else JAVACMD=java - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. diff --git a/integration/federation-compatibility/Dockerfile b/integration/federation-compatibility/Dockerfile index 8f0ec14a34..4733917ae4 100644 --- a/integration/federation-compatibility/Dockerfile +++ b/integration/federation-compatibility/Dockerfile @@ -1,4 +1,4 @@ -FROM gradle:7.6.0-jdk17 +FROM openjdk:17 EXPOSE 4001 RUN mkdir /app diff --git a/integration/federation-compatibility/src/main/kotlin/com/expediagroup/federation/compatibility/CustomSchema.kt b/integration/federation-compatibility/src/main/kotlin/com/expediagroup/federation/compatibility/CustomSchema.kt index a9ea0d6d29..f38df0723c 100644 --- a/integration/federation-compatibility/src/main/kotlin/com/expediagroup/federation/compatibility/CustomSchema.kt +++ b/integration/federation-compatibility/src/main/kotlin/com/expediagroup/federation/compatibility/CustomSchema.kt @@ -2,15 +2,37 @@ package com.expediagroup.federation.compatibility import com.expediagroup.graphql.generator.annotations.GraphQLDirective import com.expediagroup.graphql.generator.federation.directives.ComposeDirective +import com.expediagroup.graphql.generator.federation.directives.FEDERATION_SPEC_LATEST_URL import com.expediagroup.graphql.generator.federation.directives.LinkDirective +import com.expediagroup.graphql.generator.federation.directives.LinkImport +import com.expediagroup.graphql.generator.federation.directives.LinkedSpec import com.expediagroup.graphql.server.Schema import graphql.introspection.Introspection import org.springframework.stereotype.Component -@LinkDirective(url = "https://myspecs.dev/myCustomDirective/v1.0", import = ["@custom"]) +@LinkDirective( + url = FEDERATION_SPEC_LATEST_URL, + `as` = "federation", + import = [ + LinkImport("@composeDirective"), + LinkImport("@extends"), + LinkImport("@external"), + LinkImport("@inaccessible"), + LinkImport("@interfaceObject"), + LinkImport("@key"), + LinkImport("@override"), + LinkImport("@provides"), + LinkImport("@requires"), + LinkImport("@shareable"), + LinkImport("@tag"), + LinkImport("FieldSet") + ] +) +@LinkDirective(url = "https://myspecs.dev/mySpec/v1.0", `as` = "mySpec", import = [LinkImport("@custom")]) @ComposeDirective("@custom") @Component class CustomSchema : Schema +@LinkedSpec("mySpec") @GraphQLDirective(name = "custom", locations = [Introspection.DirectiveLocation.OBJECT]) annotation class CustomDirective diff --git a/integration/gradle-plugin-integration-tests/src/integration/resources/sdl/custom.graphql b/integration/gradle-plugin-integration-tests/src/integration/resources/sdl/custom.graphql index a5555246a0..970d9b3f4d 100644 --- a/integration/gradle-plugin-integration-tests/src/integration/resources/sdl/custom.graphql +++ b/integration/gradle-plugin-integration-tests/src/integration/resources/sdl/custom.graphql @@ -1,51 +1,21 @@ -schema @link(import : ["@composeDirective", "@extends", "@external", "@inaccessible", "@interfaceObject", "@key", "@override", "@provides", "@requires", "@shareable", "@tag", "FieldSet"], url : "https://specs.apollo.dev/federation/v2.3"){ +schema @link(url : "https://specs.apollo.dev/federation/v2.3"){ query: Query } -"Marks underlying custom directive to be included in the Supergraph schema" -directive @composeDirective(name: String!) repeatable on SCHEMA - "Marks the field, argument, input field or enum value as deprecated" directive @deprecated( "The reason for the deprecation" reason: String = "No longer supported" ) on FIELD_DEFINITION | ARGUMENT_DEFINITION | ENUM_VALUE | INPUT_FIELD_DEFINITION -"Marks target object as extending part of the federated schema" -directive @extends on OBJECT | INTERFACE - -"Marks target field as external meaning it will be resolved by federated schema" -directive @external on OBJECT | FIELD_DEFINITION - -"Marks location within schema as inaccessible from the GraphQL Gateway" -directive @inaccessible on SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION - "Directs the executor to include this field or fragment only when the `if` argument is true" directive @include( "Included when true." if: Boolean! ) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT -"Provides meta information to the router that this entity type is an interface in the supergraph." -directive @interfaceObject on OBJECT - -"Space separated list of primary keys needed to access federated object" -directive @key(fields: FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE - "Links definitions within the document to external schemas." -directive @link(import: [String], url: String!) repeatable on SCHEMA - -"Overrides fields resolution logic from other subgraph. Used for migrating fields from one subgraph to another." -directive @override(from: String!) on FIELD_DEFINITION - -"Specifies the base type field set that will be selectable by the gateway" -directive @provides(fields: FieldSet!) on FIELD_DEFINITION - -"Specifies required input field set from the base type for a resolver" -directive @requires(fields: FieldSet!) on FIELD_DEFINITION - -"Indicates that given object and/or field can be resolved by multiple subgraphs" -directive @shareable repeatable on OBJECT | FIELD_DEFINITION +directive @link(as: String, import: [link__Import], url: String!) repeatable on SCHEMA "Directs the executor to skip this field or fragment when the `if` argument is true." directive @skip( @@ -59,9 +29,6 @@ directive @specifiedBy( url: String! ) on SCALAR -"Allows users to annotate fields and types with additional metadata information" -directive @tag(name: String!) repeatable on SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION - type Query { _service: _Service! helloWorld(name: String): String! @@ -72,8 +39,7 @@ type _Service { sdl: String! } -"Federation type representing set of fields" -scalar FieldSet - "Custom scalar representing UUID" scalar UUID + +scalar link__Import diff --git a/integration/gradle-plugin-integration-tests/src/integration/resources/sdl/federated.graphql b/integration/gradle-plugin-integration-tests/src/integration/resources/sdl/federated.graphql index ce4a727c30..70a9fb3547 100644 --- a/integration/gradle-plugin-integration-tests/src/integration/resources/sdl/federated.graphql +++ b/integration/gradle-plugin-integration-tests/src/integration/resources/sdl/federated.graphql @@ -1,51 +1,21 @@ -schema @link(import : ["@composeDirective", "@extends", "@external", "@inaccessible", "@interfaceObject", "@key", "@override", "@provides", "@requires", "@shareable", "@tag", "FieldSet"], url : "https://specs.apollo.dev/federation/v2.3"){ +schema @link(url : "https://specs.apollo.dev/federation/v2.3"){ query: Query } -"Marks underlying custom directive to be included in the Supergraph schema" -directive @composeDirective(name: String!) repeatable on SCHEMA - "Marks the field, argument, input field or enum value as deprecated" directive @deprecated( "The reason for the deprecation" reason: String = "No longer supported" ) on FIELD_DEFINITION | ARGUMENT_DEFINITION | ENUM_VALUE | INPUT_FIELD_DEFINITION -"Marks target object as extending part of the federated schema" -directive @extends on OBJECT | INTERFACE - -"Marks target field as external meaning it will be resolved by federated schema" -directive @external on OBJECT | FIELD_DEFINITION - -"Marks location within schema as inaccessible from the GraphQL Gateway" -directive @inaccessible on SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION - "Directs the executor to include this field or fragment only when the `if` argument is true" directive @include( "Included when true." if: Boolean! ) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT -"Provides meta information to the router that this entity type is an interface in the supergraph." -directive @interfaceObject on OBJECT - -"Space separated list of primary keys needed to access federated object" -directive @key(fields: FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE - "Links definitions within the document to external schemas." -directive @link(import: [String], url: String!) repeatable on SCHEMA - -"Overrides fields resolution logic from other subgraph. Used for migrating fields from one subgraph to another." -directive @override(from: String!) on FIELD_DEFINITION - -"Specifies the base type field set that will be selectable by the gateway" -directive @provides(fields: FieldSet!) on FIELD_DEFINITION - -"Specifies required input field set from the base type for a resolver" -directive @requires(fields: FieldSet!) on FIELD_DEFINITION - -"Indicates that given object and/or field can be resolved by multiple subgraphs" -directive @shareable repeatable on OBJECT | FIELD_DEFINITION +directive @link(as: String, import: [link__Import], url: String!) repeatable on SCHEMA "Directs the executor to skip this field or fragment when the `if` argument is true." directive @skip( @@ -59,9 +29,6 @@ directive @specifiedBy( url: String! ) on SCALAR -"Allows users to annotate fields and types with additional metadata information" -directive @tag(name: String!) repeatable on SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION - type Query { _service: _Service! helloWorld(name: String): String! @@ -71,5 +38,4 @@ type _Service { sdl: String! } -"Federation type representing set of fields" -scalar FieldSet +scalar link__Import diff --git a/integration/maven-plugin-integration-tests/integration/generate-sdl-federated/src/test/kotlin/com/expediagroup/graphql/plugin/maven/GenerateSDLMojoTest.kt b/integration/maven-plugin-integration-tests/integration/generate-sdl-federated/src/test/kotlin/com/expediagroup/graphql/plugin/maven/GenerateSDLMojoTest.kt index 24eb368ebe..951691bd31 100755 --- a/integration/maven-plugin-integration-tests/integration/generate-sdl-federated/src/test/kotlin/com/expediagroup/graphql/plugin/maven/GenerateSDLMojoTest.kt +++ b/integration/maven-plugin-integration-tests/integration/generate-sdl-federated/src/test/kotlin/com/expediagroup/graphql/plugin/maven/GenerateSDLMojoTest.kt @@ -36,54 +36,24 @@ class GenerateSDLMojoTest { assertTrue(schemaFile.exists(), "schema file was generated") val expectedSchema = """ - schema @link(import : ["@composeDirective", "@extends", "@external", "@inaccessible", "@interfaceObject", "@key", "@override", "@provides", "@requires", "@shareable", "@tag", "FieldSet"], url : "https://specs.apollo.dev/federation/v2.3"){ + schema @link(url : "https://specs.apollo.dev/federation/v2.3"){ query: Query } - "Marks underlying custom directive to be included in the Supergraph schema" - directive @composeDirective(name: String!) repeatable on SCHEMA - "Marks the field, argument, input field or enum value as deprecated" directive @deprecated( "The reason for the deprecation" reason: String = "No longer supported" ) on FIELD_DEFINITION | ARGUMENT_DEFINITION | ENUM_VALUE | INPUT_FIELD_DEFINITION - "Marks target object as extending part of the federated schema" - directive @extends on OBJECT | INTERFACE - - "Marks target field as external meaning it will be resolved by federated schema" - directive @external on OBJECT | FIELD_DEFINITION - - "Marks location within schema as inaccessible from the GraphQL Gateway" - directive @inaccessible on SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION - "Directs the executor to include this field or fragment only when the `if` argument is true" directive @include( "Included when true." if: Boolean! ) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT - "Provides meta information to the router that this entity type is an interface in the supergraph." - directive @interfaceObject on OBJECT - - "Space separated list of primary keys needed to access federated object" - directive @key(fields: FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE - "Links definitions within the document to external schemas." - directive @link(import: [String], url: String!) repeatable on SCHEMA - - "Overrides fields resolution logic from other subgraph. Used for migrating fields from one subgraph to another." - directive @override(from: String!) on FIELD_DEFINITION - - "Specifies the base type field set that will be selectable by the gateway" - directive @provides(fields: FieldSet!) on FIELD_DEFINITION - - "Specifies required input field set from the base type for a resolver" - directive @requires(fields: FieldSet!) on FIELD_DEFINITION - - "Indicates that given object and/or field can be resolved by multiple subgraphs" - directive @shareable repeatable on OBJECT | FIELD_DEFINITION + directive @link(as: String, import: [link__Import], url: String!) repeatable on SCHEMA "Directs the executor to skip this field or fragment when the `if` argument is true." directive @skip( @@ -97,9 +67,6 @@ class GenerateSDLMojoTest { url: String! ) on SCALAR - "Allows users to annotate fields and types with additional metadata information" - directive @tag(name: String!) repeatable on SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION - type Query { _service: _Service! helloWorld(name: String): String! @@ -109,8 +76,7 @@ class GenerateSDLMojoTest { sdl: String! } - "Federation type representing set of fields" - scalar FieldSet + scalar link__Import """.trimIndent() assertEquals(expectedSchema, schemaFile.readText().trim()) } diff --git a/integration/maven-plugin-integration-tests/integration/generate-sdl-hooks/src/test/kotlin/com/expediagroup/graphql/plugin/maven/GenerateSDLMojoTest.kt b/integration/maven-plugin-integration-tests/integration/generate-sdl-hooks/src/test/kotlin/com/expediagroup/graphql/plugin/maven/GenerateSDLMojoTest.kt index 0121fa9f58..c596dc2bce 100755 --- a/integration/maven-plugin-integration-tests/integration/generate-sdl-hooks/src/test/kotlin/com/expediagroup/graphql/plugin/maven/GenerateSDLMojoTest.kt +++ b/integration/maven-plugin-integration-tests/integration/generate-sdl-hooks/src/test/kotlin/com/expediagroup/graphql/plugin/maven/GenerateSDLMojoTest.kt @@ -36,54 +36,24 @@ class GenerateSDLMojoTest { assertTrue(schemaFile.exists(), "schema file was generated") val expectedSchema = """ - schema @link(import : ["@composeDirective", "@extends", "@external", "@inaccessible", "@interfaceObject", "@key", "@override", "@provides", "@requires", "@shareable", "@tag", "FieldSet"], url : "https://specs.apollo.dev/federation/v2.3"){ + schema @link(url : "https://specs.apollo.dev/federation/v2.3"){ query: Query } - "Marks underlying custom directive to be included in the Supergraph schema" - directive @composeDirective(name: String!) repeatable on SCHEMA - "Marks the field, argument, input field or enum value as deprecated" directive @deprecated( "The reason for the deprecation" reason: String = "No longer supported" ) on FIELD_DEFINITION | ARGUMENT_DEFINITION | ENUM_VALUE | INPUT_FIELD_DEFINITION - "Marks target object as extending part of the federated schema" - directive @extends on OBJECT | INTERFACE - - "Marks target field as external meaning it will be resolved by federated schema" - directive @external on OBJECT | FIELD_DEFINITION - - "Marks location within schema as inaccessible from the GraphQL Gateway" - directive @inaccessible on SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION - "Directs the executor to include this field or fragment only when the `if` argument is true" directive @include( "Included when true." if: Boolean! ) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT - "Provides meta information to the router that this entity type is an interface in the supergraph." - directive @interfaceObject on OBJECT - - "Space separated list of primary keys needed to access federated object" - directive @key(fields: FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE - "Links definitions within the document to external schemas." - directive @link(import: [String], url: String!) repeatable on SCHEMA - - "Overrides fields resolution logic from other subgraph. Used for migrating fields from one subgraph to another." - directive @override(from: String!) on FIELD_DEFINITION - - "Specifies the base type field set that will be selectable by the gateway" - directive @provides(fields: FieldSet!) on FIELD_DEFINITION - - "Specifies required input field set from the base type for a resolver" - directive @requires(fields: FieldSet!) on FIELD_DEFINITION - - "Indicates that given object and/or field can be resolved by multiple subgraphs" - directive @shareable repeatable on OBJECT | FIELD_DEFINITION + directive @link(as: String, import: [link__Import], url: String!) repeatable on SCHEMA "Directs the executor to skip this field or fragment when the `if` argument is true." directive @skip( @@ -97,9 +67,6 @@ class GenerateSDLMojoTest { url: String! ) on SCALAR - "Allows users to annotate fields and types with additional metadata information" - directive @tag(name: String!) repeatable on SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION - type Query { _service: _Service! helloWorld(name: String): String! @@ -110,11 +77,10 @@ class GenerateSDLMojoTest { sdl: String! } - "Federation type representing set of fields" - scalar FieldSet - "Custom scalar representing UUID" scalar UUID + + scalar link__Import """.trimIndent() assertEquals(expectedSchema, schemaFile.readText().trim()) } diff --git a/plugins/schema/graphql-kotlin-sdl-generator/src/integrationTest/kotlin/com/expediagroup/graphql/plugin/schema/GenerateCustomSDLTest.kt b/plugins/schema/graphql-kotlin-sdl-generator/src/integrationTest/kotlin/com/expediagroup/graphql/plugin/schema/GenerateCustomSDLTest.kt index 12eb3835f9..8071065e52 100644 --- a/plugins/schema/graphql-kotlin-sdl-generator/src/integrationTest/kotlin/com/expediagroup/graphql/plugin/schema/GenerateCustomSDLTest.kt +++ b/plugins/schema/graphql-kotlin-sdl-generator/src/integrationTest/kotlin/com/expediagroup/graphql/plugin/schema/GenerateCustomSDLTest.kt @@ -25,54 +25,24 @@ class GenerateCustomSDLTest { fun `verify we can generate SDL using custom hooks provider`() { val expectedSchema = """ - schema @link(import : ["@composeDirective", "@extends", "@external", "@inaccessible", "@interfaceObject", "@key", "@override", "@provides", "@requires", "@shareable", "@tag", "FieldSet"], url : "https://specs.apollo.dev/federation/v2.3"){ + schema @link(url : "https://specs.apollo.dev/federation/v2.3"){ query: Query } - "Marks underlying custom directive to be included in the Supergraph schema" - directive @composeDirective(name: String!) repeatable on SCHEMA - "Marks the field, argument, input field or enum value as deprecated" directive @deprecated( "The reason for the deprecation" reason: String = "No longer supported" ) on FIELD_DEFINITION | ARGUMENT_DEFINITION | ENUM_VALUE | INPUT_FIELD_DEFINITION - "Marks target object as extending part of the federated schema" - directive @extends on OBJECT | INTERFACE - - "Marks target field as external meaning it will be resolved by federated schema" - directive @external on OBJECT | FIELD_DEFINITION - - "Marks location within schema as inaccessible from the GraphQL Gateway" - directive @inaccessible on SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION - "Directs the executor to include this field or fragment only when the `if` argument is true" directive @include( "Included when true." if: Boolean! ) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT - "Provides meta information to the router that this entity type is an interface in the supergraph." - directive @interfaceObject on OBJECT - - "Space separated list of primary keys needed to access federated object" - directive @key(fields: FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE - "Links definitions within the document to external schemas." - directive @link(import: [String], url: String!) repeatable on SCHEMA - - "Overrides fields resolution logic from other subgraph. Used for migrating fields from one subgraph to another." - directive @override(from: String!) on FIELD_DEFINITION - - "Specifies the base type field set that will be selectable by the gateway" - directive @provides(fields: FieldSet!) on FIELD_DEFINITION - - "Specifies required input field set from the base type for a resolver" - directive @requires(fields: FieldSet!) on FIELD_DEFINITION - - "Indicates that given object and/or field can be resolved by multiple subgraphs" - directive @shareable repeatable on OBJECT | FIELD_DEFINITION + directive @link(as: String, import: [link__Import], url: String!) repeatable on SCHEMA "Directs the executor to skip this field or fragment when the `if` argument is true." directive @skip( @@ -86,9 +56,6 @@ class GenerateCustomSDLTest { url: String! ) on SCALAR - "Allows users to annotate fields and types with additional metadata information" - directive @tag(name: String!) repeatable on SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION - type Query { _service: _Service! helloWorld(name: String): String! @@ -99,11 +66,10 @@ class GenerateCustomSDLTest { sdl: String! } - "Federation type representing set of fields" - scalar FieldSet - "Custom scalar representing UUID" scalar UUID + + scalar link__Import """.trimIndent() val generatedSchema = generateSDL(listOf("com.expediagroup.graphql.plugin.test")) diff --git a/website/docs/schema-generator/federation/federated-directives.md b/website/docs/schema-generator/federation/federated-directives.md index baa21f069e..d34f13273f 100644 --- a/website/docs/schema-generator/federation/federated-directives.md +++ b/website/docs/schema-generator/federation/federated-directives.md @@ -43,7 +43,7 @@ it will generate following schema schema @composeDirective(name: "@custom") @link(import : ["@custom"], url: "https://myspecs.dev/myCustomDirective/v1.0") -@link(import : ["@composeDirective", "@extends", "@external", "@inaccessible", "@interfaceObject", "@key", "@override", "@provides", "@requires", "@shareable", "@tag", "FieldSet"], url : "https://specs.apollo.dev/federation/v2.3") +@link(url : "https://specs.apollo.dev/federation/v2.3") { query: Query } @@ -349,34 +349,80 @@ This allows end users to query GraphQL Gateway for any product review fields and Only available in Federation v2. ::: +:::caution +While both custom namespace (`as`) and `import` arguments are optional in the schema definition, due to [#1830](https://github.com/ExpediaGroup/graphql-kotlin/issues/1830) +we currently always require those values to be explicitly provided. +::: + ```graphql -directive @link(url: String!, import: [String]) repeatable on SCHEMA +directive @link(url: String!, as: String, import: [Import]) repeatable on SCHEMA +scalar Import ``` The `@link` directive links definitions within the document to external schemas. See [@link specification](https://specs.apollo.dev/link/v1.0) for details. -External schemas are identified by their `url`, which optionally ends with a name and version with the following format: `{NAME}/v{MAJOR}.{MINOR}`. +External schemas are identified by their `url`, which ends with a name and version with the following format: `{NAME}/v{MAJOR}.{MINOR}`, +e.g. `url = "https://specs.apollo.dev/federation/v2.3"`. + +External types are associated with the target specification by annotating it with `@LinkedSpec` meta annotation. External +types defined in the specification will be automatically namespaced (prefixed with `{NAME}__`) unless they are explicitly +imported. Namespace should default to the specification name from the imported spec url. Custom namespace can be provided +by specifying `as` argument value. -By default, external types should be namespaced (prefixed with `__`, e.g. `key` directive should be namespaced as `federation__key`) unless they are explicitly imported. -`graphql-kotlin` automatically imports ALL federation directives to avoid the need for namespacing. +External types can be imported using the same name or can be aliased to some custom name. ```kotlin -@LinkDirective(url = "https://myspecs.company.dev/foo/v1.0", imports = ["@foo", "bar"]) +@LinkDirective(`as` = "custom", imports = [LinkImport(name = "@foo"), LinkImport(name = "@bar", `as` = "@myBar")], url = "https://myspecs.dev/custom/v1.0") class MySchema ``` This will generate following schema: ```graphql -schema @link(import : ["@foo", "bar"], url : "https://myspecs.company.dev/foo/v1.0") { +schema @link(as: "custom", import : ["@foo", { name: "@bar", as: "@myBar" }], url : "https://myspecs.dev/custom/v1.0") { query: Query } ``` -:::danger -We currently DO NOT support full `@link` directive capability as it requires support for namespacing and renaming imports. This functionality may be added in the future releases. See -[@link specification](https://specs.apollo.dev/link/v1.0) for details. -::: +### `@LinkedSpec` annotation + +When importing custom specifications, we need to be able to identify whether given element is part of the referenced specification. +`@LinkedSpec` is a meta annotation that is used to indicate that given directive/type is associated with imported `@link` specification. + +In order to ensure consistent behavior, `@LinkedSpec` value have to match default specification name as it appears in the +imported url and not the aliased value. + +Example usage: + +``` +@LinkedSpec("custom") +@GraphQLDirective( + name = "foo", + locations = [DirectiveLocation.FIELD_DEFINITION] +) +annotation class Foo +``` + +In the example above, we specify that `@foo` directive is part of the `custom` specification. We can then reference `@foo` +in the `@link` specification imports + +```graphql +schema @link(as: "custom", import : ["@foo"], url : "https://myspecs.dev/custom/v1.0") { + query: Query +} + +directive @foo on FIELD_DEFINITION +``` + +If we don't import the directive, then it will automatically namespaced to the spec + +```graphql +schema @link(as: "custom", url : "https://myspecs.dev/custom/v1.0") { + query: Query +} + +directive @custom__foo on FIELD_DEFINITION +``` ## `@override` directive