Skip to content

Commit

Permalink
BREAKING CHANGE: empty complex types should fail schema generation (E…
Browse files Browse the repository at this point in the history
…xpediaGroup#541)

* BREAKING CHANGE: empty complex types should fail schema generation

GraphQL specification requires Query type to be present as it is required to run introspection query. Per specification it also shouldn't be possible to generate empty complex types (objects, input objects or interfaces) and they should expose at least a single field. Since root Query type is a special GraphQLObjectType it also has to expose at least a single field.

Breaking changes:
* at least single Query is required when generating the schema
* split `didGenerateQueryType` hook (and corresponding `Mutation` and `Subscription` hooks) into `didGenerateQueryFieldType` (previous functionality) and `didGenerateQueryObjectType` hooks to allow more granular control when generating the schema
* default `SchemaGeneratorHooks` now performs validation of generated object types (including special query, mutation and subscription types), input object types and interfaces to ensure we don't generate empty complex object types

see: graphql/graphql-spec#490 and graphql/graphql-spec#568
  • Loading branch information
dariuszkuc authored Jan 10, 2020
1 parent b1423ce commit 8e848ab
Show file tree
Hide file tree
Showing 28 changed files with 413 additions and 78 deletions.
4 changes: 2 additions & 2 deletions detekt.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ complexity:
threshold: 10
active: true
TooManyFunctions:
thresholdInInterfaces: 15
thresholdInInterfaces: 20
thresholdInClasses: 15
thresholdInFiles: 15
ComplexInterface:
threshold: 15
threshold: 20

naming:
FunctionMaxLength:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ open class FederatedSchemaGeneratorHooks(private val federatedTypeRegistry: Fede
return federatedSchema.query(federatedQuery.build())
.codeRegistry(federatedCodeRegistry.build())
}

// skip validation for empty query type - federation will add _service query
override fun didGenerateQueryObject(type: GraphQLObjectType): GraphQLObjectType = type
}

private fun TypeResolutionEnvironment.getObjectName(): String? {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2019 Expedia, Inc
* Copyright 2020 Expedia, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2019 Expedia, Inc
* Copyright 2020 Expedia, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -108,7 +108,7 @@ class FederatedSchemaGeneratorTest {
hooks = FederatedSchemaGeneratorHooks(FederatedTypeRegistry())
)

val schema = toFederatedSchema(config)
val schema = toFederatedSchema(config = config)
assertEquals(FEDERATED_SDL, schema.print().trim())
val productType = schema.getObjectType("Book")
assertNotNull(productType)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2019 Expedia, Inc
* Copyright 2020 Expedia, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -16,18 +16,21 @@

package com.expediagroup.graphql.federation.data

import com.expediagroup.graphql.TopLevelObject
import com.expediagroup.graphql.federation.FederatedSchemaGeneratorConfig
import com.expediagroup.graphql.federation.FederatedSchemaGeneratorHooks
import com.expediagroup.graphql.federation.execution.FederatedTypeRegistry
import com.expediagroup.graphql.federation.execution.FederatedTypeResolver
import com.expediagroup.graphql.federation.toFederatedSchema
import graphql.schema.GraphQLSchema

internal fun federatedTestSchema(federatedTypeResolvers: Map<String, FederatedTypeResolver<*>> = emptyMap()): GraphQLSchema {
internal fun federatedTestSchema(
queries: List<TopLevelObject> = emptyList(),
federatedTypeResolvers: Map<String, FederatedTypeResolver<*>> = emptyMap()
): GraphQLSchema {
val config = FederatedSchemaGeneratorConfig(
supportedPackages = listOf("com.expediagroup.graphql.federation.data.queries.federated"),
hooks = FederatedSchemaGeneratorHooks(FederatedTypeRegistry(federatedTypeResolvers))
)

return toFederatedSchema(config)
return toFederatedSchema(config = config, queries = queries)
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2019 Expedia, Inc
* Copyright 2020 Expedia, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -16,12 +16,12 @@

package com.expediagroup.graphql.federation.execution

import graphql.ExecutionInput
import graphql.GraphQL
import org.junit.jupiter.api.Test
import com.expediagroup.graphql.federation.data.BookResolver
import com.expediagroup.graphql.federation.data.UserResolver
import com.expediagroup.graphql.federation.data.federatedTestSchema
import graphql.ExecutionInput
import graphql.GraphQL
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
Expand All @@ -47,7 +47,9 @@ class FederatedQueryResolverTest {

@Test
fun `verify can resolve federated entities`() {
val schema = federatedTestSchema(mapOf("Book" to BookResolver(), "User" to UserResolver()))
val schema = federatedTestSchema(
federatedTypeResolvers = mapOf("Book" to BookResolver(), "User" to UserResolver())
)
val userRepresentation = mapOf<String, Any>("__typename" to "User", "userId" to 123, "name" to "testName")
val book1Representation = mapOf<String, Any>("__typename" to "Book", "id" to 987, "weight" to 2.0)
val book2Representation = mapOf<String, Any>("__typename" to "Book", "id" to 988, "weight" to 1.0)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2019 Expedia, Inc
* Copyright 2020 Expedia, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -19,12 +19,12 @@ package com.expediagroup.graphql.federation.execution
import com.expediagroup.graphql.TopLevelObject
import com.expediagroup.graphql.federation.FederatedSchemaGeneratorConfig
import com.expediagroup.graphql.federation.FederatedSchemaGeneratorHooks
import com.expediagroup.graphql.federation.data.queries.simple.NestedQuery
import com.expediagroup.graphql.federation.data.queries.simple.SimpleQuery
import com.expediagroup.graphql.federation.toFederatedSchema
import graphql.ExecutionInput
import graphql.GraphQL
import org.junit.jupiter.api.Test
import com.expediagroup.graphql.federation.data.queries.simple.NestedQuery
import com.expediagroup.graphql.federation.data.queries.simple.SimpleQuery
import kotlin.test.assertEquals
import kotlin.test.assertNotNull

Expand Down Expand Up @@ -76,7 +76,7 @@ class ServiceQueryResolverTest {
hooks = FederatedSchemaGeneratorHooks(FederatedTypeRegistry())
)

val schema = toFederatedSchema(config)
val schema = toFederatedSchema(config = config)
val query = """
query sdlQuery {
_service {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* Copyright 2020 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.exceptions

import com.expediagroup.graphql.generator.extensions.getSimpleName
import kotlin.reflect.KType

/**
* Thrown when schema input object type does not expose any fields. Since GraphQL always requires you to explicitly specify all the fields down to scalar values, using an input object type without
* any defined fields would result in a field that is impossible to query without producing an error.
*
* @see [Issue 568](https://github.com/graphql/graphql-spec/issues/568)
*/
class EmptyInputObjectTypeException(ktype: KType) : GraphQLKotlinException("Invalid ${ktype.getSimpleName(isInputType = true)} input object type - input object does not expose any fields.")
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* Copyright 2020 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.exceptions

import com.expediagroup.graphql.generator.extensions.getSimpleName
import kotlin.reflect.KType

/**
* Thrown when interface type does not expose any fields - this should never happen unless we explicitly exclude fields from interface either through
* annotating fields with @GraphQLIgnore or by custom hooks that filter out available functions. Since GraphQL always requires you to select fields down
* to scalar values, an object type without any defined fields cannot be accessed in any way in a query.
*
* @see [Issue 568](https://github.com/graphql/graphql-spec/issues/568)
*/
class EmptyInterfaceTypeException(ktype: KType) : GraphQLKotlinException("Invalid ${ktype.getSimpleName()} interface type - interface does not expose any fields.")
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* Copyright 2020 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.exceptions

/**
* Thrown when generated GraphQL Mutation type does not expose any fields.
*
* @see [Issue 568](https://github.com/graphql/graphql-spec/issues/568)
*/
object EmptyMutationTypeException : GraphQLKotlinException("Invalid mutation object type - no valid mutations are available.")
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* Copyright 2020 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.exceptions

import com.expediagroup.graphql.generator.extensions.getSimpleName
import kotlin.reflect.KType

/**
* Thrown when schema object type does not expose any fields. Since GraphQL always requires you to select fields down to scalar values, an object type without any defined fields cannot be accessed
* in any way in a query.
*
* @see [Issue 568](https://github.com/graphql/graphql-spec/issues/568)
*/
class EmptyObjectTypeException(ktype: KType) : GraphQLKotlinException("Invalid ${ktype.getSimpleName()} object type - object does not expose any fields.")
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* Copyright 2020 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.exceptions

/**
* Thrown when generated GraphQL Query type does not expose any fields.
*
* @see [Issue 568](https://github.com/graphql/graphql-spec/issues/568)
*/
object EmptyQueryTypeException : GraphQLKotlinException("Invalid query object type - no valid queries are available.")
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* Copyright 2020 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.exceptions

/**
* Thrown when generated GraphQL Subscription type does not expose any fields.
*
* @see [Issue 568](https://github.com/graphql/graphql-spec/issues/568)
*/
object EmptySubscriptionTypeException : GraphQLKotlinException("Invalid subscription object type - no valid subscriptions are available.")
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2019 Expedia, Inc
* Copyright 2020 Expedia, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -16,10 +16,12 @@

package com.expediagroup.graphql.exceptions

import kotlin.reflect.KClass

/**
* Thrown when a interface implements another interface or abstract class that is not exluded from the schema.
* Thrown when an interface implements another interface or abstract class that is not excluded from the schema.
*
* This is an invalid schema until the GraphQL spec is updated
* https://github.com/ExpediaGroup/graphql-kotlin/issues/419
*/
class InvalidInterfaceException : GraphQLKotlinException("Interfaces can not have any superclasses")
class InvalidInterfaceException(klazz: KClass<*>) : GraphQLKotlinException("Invalid ${klazz.simpleName} interface - interfaces can not have any superclasses.")
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2019 Expedia, Inc
* Copyright 2020 Expedia, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -34,7 +34,7 @@ import kotlin.reflect.full.createType
internal fun generateInterface(generator: SchemaGenerator, kClass: KClass<*>): GraphQLInterfaceType {
// Interfaces can not implement another interface in GraphQL
if (kClass.getValidSuperclasses(generator.config.hooks).isNotEmpty()) {
throw InvalidInterfaceException()
throw InvalidInterfaceException(klazz = kClass)
}

val builder = GraphQLInterfaceType.newInterface()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2019 Expedia, Inc
* Copyright 2020 Expedia, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -24,7 +24,6 @@ import com.expediagroup.graphql.generator.extensions.isNotPublic
import graphql.schema.GraphQLObjectType

fun generateMutations(generator: SchemaGenerator, mutations: List<TopLevelObject>): GraphQLObjectType? {

if (mutations.isEmpty()) {
return null
}
Expand All @@ -44,10 +43,10 @@ fun generateMutations(generator: SchemaGenerator, mutations: List<TopLevelObject
mutation.kClass.getValidFunctions(generator.config.hooks)
.forEach {
val function = generateFunction(generator, it, generator.config.topLevelNames.mutation, mutation.obj)
val functionFromHook = generator.config.hooks.didGenerateMutationType(mutation.kClass, it, function)
val functionFromHook = generator.config.hooks.didGenerateMutationField(mutation.kClass, it, function)
mutationBuilder.field(functionFromHook)
}
}

return mutationBuilder.build()
return generator.config.hooks.didGenerateMutationObject(mutationBuilder.build())
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2019 Expedia, Inc
* Copyright 2020 Expedia, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -39,10 +39,10 @@ fun generateQueries(generator: SchemaGenerator, queries: List<TopLevelObject>):
query.kClass.getValidFunctions(generator.config.hooks)
.forEach {
val function = generateFunction(generator, it, generator.config.topLevelNames.query, query.obj)
val functionFromHook = generator.config.hooks.didGenerateQueryType(query.kClass, it, function)
val functionFromHook = generator.config.hooks.didGenerateQueryField(query.kClass, it, function)
queryBuilder.field(functionFromHook)
}
}

return queryBuilder.build()
return generator.config.hooks.didGenerateQueryObject(queryBuilder.build())
}
Loading

0 comments on commit 8e848ab

Please sign in to comment.