diff --git a/common/schema-builder/pom.xml b/common/schema-builder/pom.xml index 52422d7f0..48b0dfbcc 100644 --- a/common/schema-builder/pom.xml +++ b/common/schema-builder/pom.xml @@ -25,7 +25,14 @@ io.smallrye jandex - + + + + org.jetbrains.kotlinx + kotlinx-metadata-jvm + ${version.kotlinx.metadata.jvm} + + org.jboss.logging @@ -61,6 +68,16 @@ jakarta.validation-api test + + io.smallrye.reactive + mutiny + test + + + jakarta.json + jakarta.json-api + test + @@ -73,6 +90,27 @@ false + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${version.kotlin.compiler} + + + test-compile + + test-compile + + + + ${project.basedir}/src/test/kotlin + + + + + + + diff --git a/common/schema-builder/src/main/java/io/smallrye/graphql/schema/Annotations.java b/common/schema-builder/src/main/java/io/smallrye/graphql/schema/Annotations.java index 25f2636ff..a5821babf 100644 --- a/common/schema-builder/src/main/java/io/smallrye/graphql/schema/Annotations.java +++ b/common/schema-builder/src/main/java/io/smallrye/graphql/schema/Annotations.java @@ -594,6 +594,7 @@ private static Map getAnnotationsWithFilter(org.jbo public static final DotName DIRECTIVE = DotName.createSimple("io.smallrye.graphql.api.Directive"); public static final DotName DEFAULT_NON_NULL = DotName.createSimple("io.smallrye.graphql.api.DefaultNonNull"); public static final DotName NULLABLE = DotName.createSimple("io.smallrye.graphql.api.Nullable"); + public static final DotName KOTLIN_METADATA = DotName.createSimple("kotlin.Metadata"); // MicroProfile GraphQL Annotations public static final DotName GRAPHQL_API = DotName.createSimple("org.eclipse.microprofile.graphql.GraphQLApi"); diff --git a/common/schema-builder/src/main/java/io/smallrye/graphql/schema/creator/OperationCreator.java b/common/schema-builder/src/main/java/io/smallrye/graphql/schema/creator/OperationCreator.java index 77426702e..b946d6c63 100644 --- a/common/schema-builder/src/main/java/io/smallrye/graphql/schema/creator/OperationCreator.java +++ b/common/schema-builder/src/main/java/io/smallrye/graphql/schema/creator/OperationCreator.java @@ -5,6 +5,7 @@ import java.util.Objects; import java.util.Optional; +import org.jboss.jandex.AnnotationInstance; import org.jboss.jandex.DotName; import org.jboss.jandex.MethodInfo; import org.jboss.jandex.Type; @@ -21,6 +22,14 @@ import io.smallrye.graphql.schema.model.Operation; import io.smallrye.graphql.schema.model.OperationType; import io.smallrye.graphql.schema.model.Reference; +import kotlinx.metadata.Flag; +import kotlinx.metadata.KmClassifier; +import kotlinx.metadata.KmFunction; +import kotlinx.metadata.KmType; +import kotlinx.metadata.KmTypeProjection; +import kotlinx.metadata.KmValueParameter; +import kotlinx.metadata.jvm.KotlinClassHeader; +import kotlinx.metadata.jvm.KotlinClassMetadata; /** * Creates a Operation object @@ -94,9 +103,73 @@ public Operation createOperation(MethodInfo methodInfo, OperationType operationT addDirectivesForRolesAllowed(annotationsForMethod, annotationsForClass, operation, reference); populateField(Direction.OUT, operation, fieldType, annotationsForMethod); + if (operation.hasWrapper()) { + checkWrappedTypeKotlinNullability(methodInfo, annotationsForClass, operation); + } return operation; } + // If the operation return type is a wrapper and is written in Kotlin, + // this checks whether the wrapped type is nullable. + // Nullability metadata is stored in the kotlin.Metadata annotation + // on the class that contains the operation. + private void checkWrappedTypeKotlinNullability(MethodInfo methodInfo, + Annotations annotationsForClass, + Operation operation) { + Optional kotlinMetadataAnnotation = annotationsForClass + .getOneOfTheseAnnotations(Annotations.KOTLIN_METADATA); + if (kotlinMetadataAnnotation.isPresent()) { + KotlinClassMetadata.Class kotlinClass = toKotlinClassMetadata(kotlinMetadataAnnotation.get()); + // We need to find the corresponding function inside + // the KotlinClassMetadata to check its IS_NULLABLE flag. + Optional function = kotlinClass.getKmClass().getFunctions() + .stream() + .filter(f -> f.getName().equals(methodInfo.name())) + .filter(f -> compareParameterLists(f.getValueParameters(), methodInfo.parameterTypes())) + .findAny(); + if (function.isPresent()) { + KmType returnType = function.get().getReturnType(); + KmTypeProjection arg = returnType.getArguments().get(0); + int flags = arg.getType().getFlags(); + boolean nullable = Flag.Type.IS_NULLABLE.invoke(flags); + if (nullable) { + operation.setNotNull(false); + } + } + } + } + + private boolean compareParameterLists(List kotlinParameters, + List jandexParameters) { + if (kotlinParameters.size() != jandexParameters.size()) { + return false; + } + for (int i = 0; i < kotlinParameters.size(); i++) { + // TODO: the matching of parameter types could use some improvements + // For example, it won't work for primitives. + // An Int parameter will be represented as kotlin.Int in the KotlinClassMetadata, + // but as "int" in the Jandex MethodInfo. + if (!((KmClassifier.Class) kotlinParameters.get(i).getType().classifier) + .getName().replace("/", ".") + .equals(jandexParameters.get(i).name().toString())) { + return false; + } + } + return true; + } + + private KotlinClassMetadata.Class toKotlinClassMetadata(AnnotationInstance metadata) { + KotlinClassHeader classHeader = new KotlinClassHeader( + metadata.value("k").asInt(), + metadata.value("mv").asIntArray(), + metadata.value("d1").asStringArray(), + metadata.value("d2").asStringArray(), + metadata.value("xs") != null ? metadata.value("xs").asString() : null, + metadata.value("pn") != null ? metadata.value("pn").asString() : null, + metadata.value("xi").asInt()); + return (KotlinClassMetadata.Class) KotlinClassMetadata.read(classHeader); + } + private static void validateFieldType(MethodInfo methodInfo, OperationType operationType) { Type returnType = methodInfo.returnType(); if (!operationType.equals(OperationType.MUTATION) && returnType.kind().equals(Type.Kind.VOID)) { diff --git a/common/schema-builder/src/test/java/io/smallrye/graphql/index/SchemaBuilderTest.java b/common/schema-builder/src/test/java/io/smallrye/graphql/index/SchemaBuilderTest.java index 185372afe..d538812e1 100644 --- a/common/schema-builder/src/test/java/io/smallrye/graphql/index/SchemaBuilderTest.java +++ b/common/schema-builder/src/test/java/io/smallrye/graphql/index/SchemaBuilderTest.java @@ -17,6 +17,7 @@ import java.nio.file.Paths; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.NoSuchElementException; import java.util.Set; import java.util.concurrent.ExecutorService; @@ -269,6 +270,36 @@ public void testGenericSchemaBuilding() { } + @Test + public void testKotlinTypeNullability() { + Indexer indexer = new Indexer(); + indexDirectory(indexer, "io/smallrye/graphql/kotlin"); + IndexView index = indexer.complete(); + Schema schema = SchemaBuilder.build(index); + + assertTrue(getQueryByName(schema, "notNullable").isNotNull()); + assertFalse(getQueryByName(schema, "nullable").isNotNull()); + assertTrue(getQueryByName(schema, "notNullableItemInUni").isNotNull()); + assertFalse(getQueryByName(schema, "nullableItemInUni").isNotNull()); + + Map fooSubfields = schema.getTypes().get("Foo").getOperations(); + assertTrue(fooSubfields.get("notNullableNestedItem").isNotNull()); + assertTrue(fooSubfields.get("notNullableNestedItemInUni").isNotNull()); + assertFalse(fooSubfields.get("nullableNestedItem").isNotNull()); + assertFalse(fooSubfields.get("nullableNestedItemInUni").isNotNull()); + + assertFalse(getQueryByName(schema, "zzz1").isNotNull()); + assertFalse(getQueryByName(schema, "zzz2").isNotNull()); + assertTrue(getQueryByName(schema, "zzz3").isNotNull()); + assertTrue(getQueryByName(schema, "zzz4").isNotNull()); + } + + private Operation getQueryByName(Schema schema, String name) { + return schema.getQueries() + .stream().filter(q -> q.getName().equals(name)) + .findFirst().orElseThrow(); + } + static IndexView getTCKIndex() { Indexer indexer = new Indexer(); indexDirectory(indexer, "org/eclipse/microprofile/graphql/tck/apps/basic/api"); diff --git a/common/schema-builder/src/test/kotlin/io/smallrye/graphql/kotlin/Foo.kt b/common/schema-builder/src/test/kotlin/io/smallrye/graphql/kotlin/Foo.kt new file mode 100644 index 000000000..40b6f766f --- /dev/null +++ b/common/schema-builder/src/test/kotlin/io/smallrye/graphql/kotlin/Foo.kt @@ -0,0 +1,49 @@ +package io.smallrye.graphql.kotlin + +import io.smallrye.mutiny.Uni +import org.eclipse.microprofile.graphql.GraphQLApi +import org.eclipse.microprofile.graphql.Query +import org.eclipse.microprofile.graphql.Source +import jakarta.json.bind.annotation.JsonbCreator + +data class Foo @JsonbCreator constructor(val bar: String?) +data class Foo2 @JsonbCreator constructor(val bar: String?) +data class Foo3 @JsonbCreator constructor(val bar: String?) +data class Foo4 @JsonbCreator constructor(val bar: String?) + +@GraphQLApi +class Example { + @Query + fun nullable(): Foo? = null + + @Query + fun notNullable(): Foo = Foo("blabla") + + @Query + fun nullableItemInUni(): Uni = Uni.createFrom().nullItem() + + @Query + fun notNullableItemInUni(): Uni = Uni.createFrom().item(Foo("blabla")) + + fun nullableNestedItem(@Source foo: Foo): String? = foo.bar + + fun notNullableNestedItem(@Source foo: Foo): String = "bar" + + fun nullableNestedItemInUni(@Source foo: Foo): Uni = Uni.createFrom().nullItem() + + fun notNullableNestedItemInUni(@Source foo: Foo): Uni = Uni.createFrom().item("bar") + + // some overloaded methods to make sure we correctly find the function inside KotlinClassMetadata + @Query("zzz1") + fun zzz(x: Foo): Uni = Uni.createFrom().nullItem() + + @Query("zzz2") + fun zzz(x: Foo2): Uni = Uni.createFrom().nullItem() + + @Query("zzz3") + fun zzz(x: Foo3): Uni = Uni.createFrom().nullItem() + + @Query("zzz4") + fun zzz(x: Foo4): Uni = Uni.createFrom().nullItem() + +} \ No newline at end of file diff --git a/pom.xml b/pom.xml index e17f78669..f11c278b1 100644 --- a/pom.xml +++ b/pom.xml @@ -65,6 +65,8 @@ 11 11 1.8.0 + 0.7.0 + 1.9.0