diff --git a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/validation/ValidateFieldSelection.kt b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/validation/ValidateFieldSelection.kt index 78dda00bd4..168fc55b06 100644 --- a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/validation/ValidateFieldSelection.kt +++ b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/validation/ValidateFieldSelection.kt @@ -26,7 +26,7 @@ import graphql.schema.GraphQLType import graphql.schema.GraphQLTypeUtil import graphql.schema.GraphQLUnionType -internal fun validateFieldSelection(validatedDirective: DirectiveInfo, selection: FieldSetSelection, targetType: GraphQLType, errors: MutableList) { +internal fun validateFieldSelection(validatedDirective: DirectiveInfo, selection: FieldSetSelection, targetType: GraphQLType, errors: MutableList, isExternalPath: Boolean = false) { when (val unwrapped = GraphQLTypeUtil.unwrapNonNull(targetType)) { is GraphQLScalarType, is GraphQLEnumType -> { if (selection.subSelections.isNotEmpty()) { @@ -38,7 +38,7 @@ internal fun validateFieldSelection(validatedDirective: DirectiveInfo, selection if (KEY_DIRECTIVE_NAME == validatedDirective.directiveName) { errors.add("$validatedDirective specifies invalid field set - field set references GraphQLList, field=${selection.field}") } else { - validateFieldSelection(validatedDirective, selection, GraphQLTypeUtil.unwrapOne(targetType), errors) + validateFieldSelection(validatedDirective, selection, GraphQLTypeUtil.unwrapOne(targetType), errors, isExternalPath) } } is GraphQLInterfaceType -> { @@ -51,7 +51,8 @@ internal fun validateFieldSelection(validatedDirective: DirectiveInfo, selection validatedDirective, selection.subSelections, unwrapped.fieldDefinitions.associateBy { it.name }, - errors + errors, + isExternalPath ) } } @@ -63,7 +64,8 @@ internal fun validateFieldSelection(validatedDirective: DirectiveInfo, selection validatedDirective, selection.subSelections, unwrapped.fieldDefinitions.associateBy { it.name }, - errors + errors, + isExternalPath ) } } diff --git a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/validation/validateDirective.kt b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/validation/validateDirective.kt index 46b4ddfe21..0c3d39f1ab 100644 --- a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/validation/validateDirective.kt +++ b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/validation/validateDirective.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. @@ -26,7 +26,7 @@ internal fun validateDirective( validatedType: String, targetDirective: String, directiveMap: Map>, - fieldMap: Map, + fieldMap: Map ): List { val validationErrors = mutableListOf() val directives = directiveMap[targetDirective] diff --git a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/validation/validateFieldSetSelection.kt b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/validation/validateFieldSetSelection.kt index 60b80a81e1..565a692689 100644 --- a/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/validation/validateFieldSetSelection.kt +++ b/generator/graphql-kotlin-federation/src/main/kotlin/com/expediagroup/graphql/generator/federation/validation/validateFieldSetSelection.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,13 +18,17 @@ package com.expediagroup.graphql.generator.federation.validation import com.expediagroup.graphql.generator.federation.directives.EXTERNAL_DIRECTIVE_NAME import com.expediagroup.graphql.generator.federation.directives.REQUIRES_DIRECTIVE_NAME +import graphql.schema.GraphQLDirectiveContainer import graphql.schema.GraphQLFieldDefinition +import graphql.schema.GraphQLNamedType +import graphql.schema.GraphQLTypeUtil internal fun validateFieldSetSelection( validatedDirective: DirectiveInfo, selections: List, fields: Map, - errors: MutableList + errors: MutableList, + isExternalPath: Boolean = false ) { for (selection in selections) { val currentField = fields[selection.field] @@ -32,10 +36,14 @@ internal fun validateFieldSetSelection( errors.add("$validatedDirective specifies invalid field set - field set specifies field that does not exist, field=${selection.field}") } else { val currentFieldType = currentField.type - if (REQUIRES_DIRECTIVE_NAME == validatedDirective.directiveName && currentField.getAppliedDirective(EXTERNAL_DIRECTIVE_NAME) == null) { + val isExternal = isExternalPath || GraphQLTypeUtil.unwrapAll(currentFieldType).isExternalPath() || currentField.isExternalType() + if (REQUIRES_DIRECTIVE_NAME == validatedDirective.directiveName && GraphQLTypeUtil.isLeaf(currentFieldType) && !isExternal) { errors.add("$validatedDirective specifies invalid field set - @requires should reference only @external fields, field=${selection.field}") } - validateFieldSelection(validatedDirective, selection, currentFieldType, errors) + validateFieldSelection(validatedDirective, selection, currentFieldType, errors, isExternal) } } } + +private fun GraphQLDirectiveContainer.isExternalType(): Boolean = this.getAppliedDirectives(EXTERNAL_DIRECTIVE_NAME).isNotEmpty() +private fun GraphQLNamedType.isExternalPath(): Boolean = this is GraphQLDirectiveContainer && this.isExternalType() diff --git a/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/data/integration/requires/success/_5/LeafRequires.kt b/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/data/integration/requires/success/_5/LeafRequires.kt new file mode 100644 index 0000000000..4d3a396891 --- /dev/null +++ b/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/data/integration/requires/success/_5/LeafRequires.kt @@ -0,0 +1,49 @@ +/* + * 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.data.integration.requires.success._5 + +import com.expediagroup.graphql.generator.federation.directives.ExternalDirective +import com.expediagroup.graphql.generator.federation.directives.FieldSet +import com.expediagroup.graphql.generator.federation.directives.KeyDirective +import com.expediagroup.graphql.generator.federation.directives.RequiresDirective +import kotlin.properties.Delegates + +/* +# only leaf fields have to be external when @requires references complex types +type LeafRequires @key(fields : "id") { + complexType: ComplexType! + description: String! + id: String! + shippingCost: String! @requires(fields : "complexType { weight }") +} + +type ComplexType { + localField: String + weight: Float! @external +} + */ +@KeyDirective(fields = FieldSet("id")) +class LeafRequires(val id: String, val description: String, val complexType: ComplexType) { + + @RequiresDirective(FieldSet("complexType { weight }")) + fun shippingCost(): String = "$${complexType.weight * 9.99}" +} + +class ComplexType(val localField: String) { + @ExternalDirective + var weight: Double by Delegates.notNull() +} diff --git a/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/data/integration/requires/success/_6/RecursiveExternalRequires.kt b/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/data/integration/requires/success/_6/RecursiveExternalRequires.kt new file mode 100644 index 0000000000..82b0150335 --- /dev/null +++ b/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/data/integration/requires/success/_6/RecursiveExternalRequires.kt @@ -0,0 +1,48 @@ +/* + * 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.data.integration.requires.success._6 + +import com.expediagroup.graphql.generator.federation.directives.ExternalDirective +import com.expediagroup.graphql.generator.federation.directives.FieldSet +import com.expediagroup.graphql.generator.federation.directives.KeyDirective +import com.expediagroup.graphql.generator.federation.directives.RequiresDirective +import kotlin.properties.Delegates + +/* +# @external information is applied recursively through parent fields +type RecursiveExternalRequires @key(fields : "id") { + complexType: ComplexType! @external + description: String! + id: String! + shippingCost: String! @requires(fields : "complexType { weight }") +} + +type ComplexType { + potentiallyExternal: String + weight: Float! +} + */ +@KeyDirective(fields = FieldSet("id")) +class RecursiveExternalRequires(val id: String, val description: String, @ExternalDirective val complexType: ComplexType) { + + @RequiresDirective(FieldSet("complexType { weight }")) + fun shippingCost(): String = "$${complexType.weight * 9.99}" +} + +class ComplexType(val potentiallyExternal: String) { + var weight: Double by Delegates.notNull() +} diff --git a/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/data/integration/requires/success/_7/ExternalRequiresType.kt b/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/data/integration/requires/success/_7/ExternalRequiresType.kt new file mode 100644 index 0000000000..c42cee0449 --- /dev/null +++ b/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/data/integration/requires/success/_7/ExternalRequiresType.kt @@ -0,0 +1,49 @@ +/* + * 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.data.integration.requires.success._7 + +import com.expediagroup.graphql.generator.federation.directives.ExternalDirective +import com.expediagroup.graphql.generator.federation.directives.FieldSet +import com.expediagroup.graphql.generator.federation.directives.KeyDirective +import com.expediagroup.graphql.generator.federation.directives.RequiresDirective +import kotlin.properties.Delegates + +/* +# @external information is applied to fields within type +type RecursiveExternalRequires @key(fields : "id") { + externalType: ExternalType! + description: String! + id: String! + shippingCost: String! @requires(fields : "complexType { weight }") +} + +type ExternalType @external { + allExternal: String + weight: Float! +} + */ +@KeyDirective(fields = FieldSet("id")) +class ExternalRequiresType(val id: String, val description: String, val externalType: ExternalType) { + + @RequiresDirective(FieldSet("externalType { weight }")) + fun shippingCost(): String = "$${externalType.weight * 9.99}" +} + +@ExternalDirective +class ExternalType(val allExternal: String) { + var weight: Double by Delegates.notNull() +} diff --git a/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/validation/integration/FederatedRequiresDirectiveIT.kt b/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/validation/integration/FederatedRequiresDirectiveIT.kt index 9f777ff408..59c6853fc2 100644 --- a/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/validation/integration/FederatedRequiresDirectiveIT.kt +++ b/generator/graphql-kotlin-federation/src/test/kotlin/com/expediagroup/graphql/generator/federation/validation/integration/FederatedRequiresDirectiveIT.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,12 +16,18 @@ package com.expediagroup.graphql.generator.federation.validation.integration +import com.expediagroup.graphql.generator.federation.directives.EXTERNAL_DIRECTIVE_NAME +import com.expediagroup.graphql.generator.federation.directives.KEY_DIRECTIVE_NAME +import com.expediagroup.graphql.generator.federation.directives.REQUIRES_DIRECTIVE_NAME import com.expediagroup.graphql.generator.federation.exception.InvalidFederatedSchema import com.expediagroup.graphql.generator.federation.toFederatedSchema +import graphql.schema.GraphQLObjectType +import graphql.schema.GraphQLTypeUtil import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertDoesNotThrow import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -32,13 +38,13 @@ class FederatedRequiresDirectiveIT { assertDoesNotThrow { val schema = toFederatedSchema(config = federatedTestConfig("com.expediagroup.graphql.generator.federation.data.integration.requires.success._1")) val validatedType = schema.getObjectType("SimpleRequires") - assertTrue(validatedType.hasAppliedDirective("key")) + assertTrue(validatedType.hasAppliedDirective(KEY_DIRECTIVE_NAME)) val weightField = validatedType.getFieldDefinition("weight") assertNotNull(weightField) - assertNotNull(weightField.hasAppliedDirective("external")) + assertTrue(weightField.hasAppliedDirective(EXTERNAL_DIRECTIVE_NAME)) val requiresField = validatedType.getFieldDefinition("shippingCost") assertNotNull(requiresField) - assertNotNull(requiresField.hasAppliedDirective("requires")) + assertTrue(requiresField.hasAppliedDirective(REQUIRES_DIRECTIVE_NAME)) } } @@ -47,13 +53,13 @@ class FederatedRequiresDirectiveIT { assertDoesNotThrow { val schema = toFederatedSchema(config = federatedTestConfig("com.expediagroup.graphql.generator.federation.data.integration.requires.success._2")) val validatedType = schema.getObjectType("RequiresSelectionOnList") - assertTrue(validatedType.hasAppliedDirective("key")) + assertTrue(validatedType.hasAppliedDirective(KEY_DIRECTIVE_NAME)) val externalField = validatedType.getFieldDefinition("email") assertNotNull(externalField) - assertNotNull(externalField.hasAppliedDirective("external")) + assertTrue(externalField.hasAppliedDirective(EXTERNAL_DIRECTIVE_NAME)) val requiresField = validatedType.getFieldDefinition("reviews") assertNotNull(requiresField) - assertNotNull(requiresField.hasAppliedDirective("requires")) + assertTrue(requiresField.hasAppliedDirective(REQUIRES_DIRECTIVE_NAME)) } } @@ -62,13 +68,13 @@ class FederatedRequiresDirectiveIT { assertDoesNotThrow { val schema = toFederatedSchema(config = federatedTestConfig("com.expediagroup.graphql.generator.federation.data.integration.requires.success._3")) val validatedType = schema.getObjectType("RequiresSelectionOnInterface") - assertTrue(validatedType.hasAppliedDirective("key")) + assertTrue(validatedType.hasAppliedDirective(KEY_DIRECTIVE_NAME)) val externalField = validatedType.getFieldDefinition("email") assertNotNull(externalField) - assertNotNull(externalField.hasAppliedDirective("external")) + assertTrue(externalField.hasAppliedDirective(EXTERNAL_DIRECTIVE_NAME)) val requiresField = validatedType.getFieldDefinition("review") assertNotNull(requiresField) - assertNotNull(requiresField.hasAppliedDirective("requires")) + assertTrue(requiresField.hasAppliedDirective(REQUIRES_DIRECTIVE_NAME)) } } @@ -77,13 +83,13 @@ class FederatedRequiresDirectiveIT { assertDoesNotThrow { val schema = toFederatedSchema(config = federatedTestConfig("com.expediagroup.graphql.generator.federation.data.integration.requires.success._4")) val validatedType = schema.getObjectType("RequiresSelectionOnUnion") - assertTrue(validatedType.hasAppliedDirective("key")) + assertTrue(validatedType.hasAppliedDirective(KEY_DIRECTIVE_NAME)) val externalField = validatedType.getFieldDefinition("email") assertNotNull(externalField) - assertNotNull(externalField.hasAppliedDirective("external")) + assertTrue(externalField.hasAppliedDirective(EXTERNAL_DIRECTIVE_NAME)) val requiresField = validatedType.getFieldDefinition("review") assertNotNull(requiresField) - assertNotNull(requiresField.hasAppliedDirective("requires")) + assertTrue(requiresField.hasAppliedDirective(REQUIRES_DIRECTIVE_NAME)) } } @@ -112,4 +118,65 @@ class FederatedRequiresDirectiveIT { """.trimIndent() assertEquals(expected, exception.message) } + + @Test + fun `verifies @requires needs @external leaf fields only`() { + assertDoesNotThrow { + val schema = toFederatedSchema(config = federatedTestConfig("com.expediagroup.graphql.generator.federation.data.integration.requires.success._5")) + val validatedType = schema.getObjectType("LeafRequires") + assertTrue(validatedType.hasAppliedDirective(KEY_DIRECTIVE_NAME)) + val localType = validatedType.getFieldDefinition("complexType")?.type + assertNotNull(localType) + val unwrappedType = GraphQLTypeUtil.unwrapAll(localType) as GraphQLObjectType + assertFalse(unwrappedType.hasAppliedDirective(EXTERNAL_DIRECTIVE_NAME)) + val externalField = unwrappedType.getField("weight") + assertTrue(externalField.hasAppliedDirective(EXTERNAL_DIRECTIVE_NAME)) + + val requiresField = validatedType.getFieldDefinition("shippingCost") + assertNotNull(requiresField) + assertTrue(requiresField.hasAppliedDirective(REQUIRES_DIRECTIVE_NAME)) + } + } + + @Test + fun `verifies @external is recursively applied for @requires selection set`() { + assertDoesNotThrow { + val schema = toFederatedSchema(config = federatedTestConfig("com.expediagroup.graphql.generator.federation.data.integration.requires.success._6")) + val validatedType = schema.getObjectType("RecursiveExternalRequires") + assertTrue(validatedType.hasAppliedDirective(KEY_DIRECTIVE_NAME)) + val externalField = validatedType.getFieldDefinition("complexType") + assertNotNull(externalField) + assertTrue(externalField.hasAppliedDirective(EXTERNAL_DIRECTIVE_NAME)) + + val unwrappedType = GraphQLTypeUtil.unwrapAll(externalField.type) as GraphQLObjectType + assertFalse(unwrappedType.hasAppliedDirective(EXTERNAL_DIRECTIVE_NAME)) + val leafField = unwrappedType.getField("weight") + assertFalse(leafField.hasAppliedDirective(EXTERNAL_DIRECTIVE_NAME)) + + val requiresField = validatedType.getFieldDefinition("shippingCost") + assertNotNull(requiresField) + assertTrue(requiresField.hasAppliedDirective(REQUIRES_DIRECTIVE_NAME)) + } + } + + @Test + fun `verifies @external is applied on all fields within external type`() { + assertDoesNotThrow { + val schema = toFederatedSchema(config = federatedTestConfig("com.expediagroup.graphql.generator.federation.data.integration.requires.success._7")) + val validatedType = schema.getObjectType("ExternalRequiresType") + assertTrue(validatedType.hasAppliedDirective(KEY_DIRECTIVE_NAME)) + val externalField = validatedType.getFieldDefinition("externalType") + assertNotNull(externalField) + assertFalse(externalField.hasAppliedDirective(EXTERNAL_DIRECTIVE_NAME)) + + val unwrappedType = GraphQLTypeUtil.unwrapAll(externalField.type) as GraphQLObjectType + assertTrue(unwrappedType.hasAppliedDirective(EXTERNAL_DIRECTIVE_NAME)) + val leafField = unwrappedType.getField("weight") + assertFalse(leafField.hasAppliedDirective(EXTERNAL_DIRECTIVE_NAME)) + + val requiresField = validatedType.getFieldDefinition("shippingCost") + assertNotNull(requiresField) + assertTrue(requiresField.hasAppliedDirective(REQUIRES_DIRECTIVE_NAME)) + } + } } diff --git a/website/docs/schema-generator/federation/federated-directives.md b/website/docs/schema-generator/federation/federated-directives.md index 76c6c16be3..baa21f069e 100644 --- a/website/docs/schema-generator/federation/federated-directives.md +++ b/website/docs/schema-generator/federation/federated-directives.md @@ -500,6 +500,8 @@ The `@requires` directive is used to specify external (provided by other subgrap the required fields may not be needed by the client, but the service may need additional information from other subgraphs. Required fields specified in the directive field set should correspond to a valid field on the underlying GraphQL interface/object and should be instrumented with `@external` directive. +All the leaf fields from the specified in the `@requires` selection set have to be marked as `@external` OR any of the parent fields on the path to the leaf is marked as `@external`. + Fields specified in the `@requires` directive will only be specified in the queries that reference those fields. This is problematic for Kotlin as the non-nullable primitive properties have to be initialized when they are declared. Simplest workaround for this problem is to initialize the underlying property to some default value (e.g. null) that will be used if it is not specified. This approach might become problematic though as it might be impossible to determine whether fields was initialized with the default value or the invalid/default