diff --git a/graphql-dgs-codegen-core/src/test/kotlin/com/netflix/graphql/dgs/codegen/CodegenTestClassLoader.kt b/graphql-dgs-codegen-core/src/test/kotlin/com/netflix/graphql/dgs/codegen/CodegenTestClassLoader.kt index 5c7d9b6d9..b4983bf6d 100644 --- a/graphql-dgs-codegen-core/src/test/kotlin/com/netflix/graphql/dgs/codegen/CodegenTestClassLoader.kt +++ b/graphql-dgs-codegen-core/src/test/kotlin/com/netflix/graphql/dgs/codegen/CodegenTestClassLoader.kt @@ -20,28 +20,31 @@ package com.netflix.graphql.dgs.codegen import com.google.testing.compile.Compilation import java.io.ByteArrayOutputStream -import java.util.Optional -import java.util.concurrent.ConcurrentHashMap import javax.tools.JavaFileObject internal class CodegenTestClassLoader( private val compilation: Compilation, parent: ClassLoader?, ) : ClassLoader(parent) { - private val seenClasses = ConcurrentHashMap>() + private val seenClasses = HashMap>() @Throws(ClassNotFoundException::class) override fun loadClass(name: String): Class<*> { val packageNameAsUnixPath = name.replace(".", "/") val normalizedName = "/CLASS_OUTPUT/$packageNameAsUnixPath.class" - return seenClasses.computeIfAbsent(normalizedName) { _ -> - Optional - .ofNullable( - compilation - .generatedFiles() - .find { it.kind == JavaFileObject.Kind.CLASS && it.name == normalizedName }, - ).map { fileObject -> + // Guard with a reentrant monitor so that defineClass() triggering a nested loadClass() + // on the same thread (e.g. for a superclass) doesn't deadlock or throw. + synchronized(seenClasses) { + seenClasses[normalizedName]?.let { return it } + + val fileObject = + compilation + .generatedFiles() + .find { it.kind == JavaFileObject.Kind.CLASS && it.name == normalizedName } + + val loaded = + if (fileObject != null) { val classData = fileObject.openInputStream().use { inputStream -> val buffer = ByteArrayOutputStream() @@ -49,7 +52,12 @@ internal class CodegenTestClassLoader( buffer.toByteArray() } defineClass(name, classData, 0, classData.size) - }.orElse(super.loadClass(name)) + } else { + super.loadClass(name) + } + + seenClasses[normalizedName] = loaded + return loaded } } } diff --git a/graphql-dgs-codegen-core/src/test/kotlin/com/netflix/graphql/dgs/codegen/JavaDeserializationTest.kt b/graphql-dgs-codegen-core/src/test/kotlin/com/netflix/graphql/dgs/codegen/JavaDeserializationTest.kt new file mode 100644 index 000000000..55e1c405b --- /dev/null +++ b/graphql-dgs-codegen-core/src/test/kotlin/com/netflix/graphql/dgs/codegen/JavaDeserializationTest.kt @@ -0,0 +1,111 @@ +/* + * + * Copyright 2020 Netflix, 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 + * + * http://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.netflix.graphql.dgs.codegen + +import com.fasterxml.jackson.databind.ObjectMapper +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class JavaDeserializationTest { + private val objectMapper = ObjectMapper() + + @Test + fun `interface deserializes to concrete subtype based on __typename`() { + val schema = + """ + type Query { + search: Show + } + + interface Show { + title: String + } + + type Movie implements Show { + title: String + duration: Int + } + + type Series implements Show { + title: String + episodes: Int + } + """.trimIndent() + + val codeGenResult = + CodeGen( + CodeGenConfig( + schemas = setOf(schema), + packageName = BASE_PACKAGE_NAME, + ), + ).generate() + + val testClassLoader = assertCompilesJava(codeGenResult).toClassLoader() + val showInterface = testClassLoader.loadClass("$TYPES_PACKAGE_NAME.Show") + val movieClass = testClassLoader.loadClass("$TYPES_PACKAGE_NAME.Movie") + + val json = """{"__typename":"Movie","title":"The Matrix","duration":136}""" + val instance = objectMapper.readValue(json, showInterface) + + assertThat(instance).isInstanceOf(movieClass) + assertThat(movieClass.getMethod("getTitle").invoke(instance)).isEqualTo("The Matrix") + assertThat(movieClass.getMethod("getDuration").invoke(instance)).isEqualTo(136) + } + + @Test + fun `union deserializes to concrete subtype based on __typename`() { + val schema = + """ + type Query { + search: Video + } + + union Video = Movie | Series + + type Movie { + title: String + duration: Int + } + + type Series { + title: String + episodes: Int + } + """.trimIndent() + + val codeGenResult = + CodeGen( + CodeGenConfig( + schemas = setOf(schema), + packageName = BASE_PACKAGE_NAME, + ), + ).generate() + + val testClassLoader = assertCompilesJava(codeGenResult).toClassLoader() + val videoInterface = testClassLoader.loadClass("$TYPES_PACKAGE_NAME.Video") + val seriesClass = testClassLoader.loadClass("$TYPES_PACKAGE_NAME.Series") + + val json = """{"__typename":"Series","title":"Arrested Development","episodes":68}""" + val instance = objectMapper.readValue(json, videoInterface) + + assertThat(instance).isInstanceOf(seriesClass) + assertThat(seriesClass.getMethod("getTitle").invoke(instance)).isEqualTo("Arrested Development") + assertThat(seriesClass.getMethod("getEpisodes").invoke(instance)).isEqualTo(68) + } +} diff --git a/graphql-dgs-codegen-core/src/test/kotlin/com/netflix/graphql/dgs/codegen/clientapi/ClientApiGenProjectionTest.kt b/graphql-dgs-codegen-core/src/test/kotlin/com/netflix/graphql/dgs/codegen/clientapi/ClientApiGenProjectionTest.kt index da238fe14..7137986f7 100644 --- a/graphql-dgs-codegen-core/src/test/kotlin/com/netflix/graphql/dgs/codegen/clientapi/ClientApiGenProjectionTest.kt +++ b/graphql-dgs-codegen-core/src/test/kotlin/com/netflix/graphql/dgs/codegen/clientapi/ClientApiGenProjectionTest.kt @@ -18,10 +18,13 @@ package com.netflix.graphql.dgs.codegen.clientapi +import com.fasterxml.jackson.databind.ObjectMapper +import com.netflix.graphql.dgs.client.codegen.BaseSubProjectionNode import com.netflix.graphql.dgs.codegen.BASE_PACKAGE_NAME import com.netflix.graphql.dgs.codegen.CodeGen import com.netflix.graphql.dgs.codegen.CodeGenConfig import com.netflix.graphql.dgs.codegen.assertCompilesJava +import com.netflix.graphql.dgs.codegen.toClassLoader import com.palantir.javapoet.TypeVariableName import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test @@ -1010,4 +1013,51 @@ class ClientApiGenProjectionTest { assertCompilesJava(codeGenResult.clientProjections + codeGenResult.javaQueryTypes) } + + @Test + fun `concrete type deserializes JSON matching fields selected by interface subprojection`() { + val schema = + """ + type Query { + search: Details + } + + type Details { + show: Show + } + + interface Show { + title: String + } + + type Movie implements Show { + title: String + duration: Int + } + """.trimIndent() + + val codeGenResult = + CodeGen( + CodeGenConfig( + schemas = setOf(schema), + packageName = BASE_PACKAGE_NAME, + generateClientApi = true, + ), + ).generate() + + val testClassLoader = assertCompilesJava(codeGenResult).toClassLoader() + val showProjection = + testClassLoader + .loadClass("$BASE_PACKAGE_NAME.client.ShowProjection") + .getDeclaredConstructor(BaseSubProjectionNode::class.java, BaseSubProjectionNode::class.java) + .newInstance(null, null) as BaseSubProjectionNode<*, *> + + val payload = mutableMapOf("title" to "The Matrix", "duration" to 136) + showProjection.fields.keys.forEach { payload.putIfAbsent(it, "Movie") } + val responseJson = ObjectMapper().writeValueAsString(payload) + + val movieClass = testClassLoader.loadClass("$BASE_PACKAGE_NAME.types.Movie") + // Should deserialize via ObjectMapper without error, no unrecognized fields + assertThat(ObjectMapper().readValue(responseJson, movieClass)).isNotNull + } }