diff --git a/.gitignore b/.gitignore index 6422e753..064524d7 100644 --- a/.gitignore +++ b/.gitignore @@ -24,5 +24,10 @@ hs_err_pid* /.idea/ build/ +# composite build +*/build/ +*/.gradle/ +*/buildSrc/.gradle/ + # OS X DS_Store .DS_Store \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 456ac6e9..da5ebb3f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,7 +10,7 @@ group = io.github.ermadmi78 version = 5.3.1-SNAPSHOT # dependencies -kotlinJdkVersion=11 +kotlinJdkVersion=17 kotlinVersion = 1.8.10 graphQLJavaVersion = 20.2 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 37aef8d3..3c44eb1b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ 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.14-bin.zip networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/kobby-gradle-tests/build.gradle.kts b/kobby-gradle-tests/build.gradle.kts new file mode 100644 index 00000000..e02e75ed --- /dev/null +++ b/kobby-gradle-tests/build.gradle.kts @@ -0,0 +1,5 @@ +tasks.register("test") { + rootProject.subprojects.forEach { + dependsOn(":${it.name}:test") + } +} diff --git a/kobby-gradle-tests/buildSrc/build.gradle.kts b/kobby-gradle-tests/buildSrc/build.gradle.kts new file mode 100644 index 00000000..ff50b7e2 --- /dev/null +++ b/kobby-gradle-tests/buildSrc/build.gradle.kts @@ -0,0 +1,35 @@ +import java.nio.file.Paths +import java.util.* + +plugins { + `kotlin-dsl` +} + +val props = Properties().apply { + Paths.get(projectDir.parentFile.parent, "gradle.properties").toFile() + .inputStream().use { load(it) } +} + +val snapshotKobbyVersion = props["version"] +val testLogger = props["testLogger"] + +dependencies { + implementation(testLibs.ktor.server.netty) + implementation(testLibs.ktor.server.websockets) + implementation(testLibs.ktor.server.cors) + + implementation(testLibs.graphql.kotlin) + implementation(testLibs.extended.scalars) { + exclude(group = "com.graphql-java", module = "graphql-java") + } + + implementation(testLibs.kotlin.gradle.plugin) + + // firstly run `publishToMavenLocal` + implementation("io.github.ermadmi78:kobby-gradle-plugin:$snapshotKobbyVersion") + implementation("com.adarshr:gradle-test-logger-plugin:$testLogger") +} + +kotlin { + jvmToolchain(props.getProperty("kotlinJdkVersion")!!.toInt()) +} diff --git a/kobby-gradle-tests/buildSrc/settings.gradle.kts b/kobby-gradle-tests/buildSrc/settings.gradle.kts new file mode 100644 index 00000000..a7c36215 --- /dev/null +++ b/kobby-gradle-tests/buildSrc/settings.gradle.kts @@ -0,0 +1,10 @@ +plugins { + id("dev.panuszewski.typesafe-conventions") version "0.10.0" +} + +dependencyResolutionManagement { + repositories { + mavenLocal() + gradlePluginPortal() + } +} diff --git a/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/ApplicationEntrypoint.kt b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/ApplicationEntrypoint.kt new file mode 100644 index 00000000..5b1a2404 --- /dev/null +++ b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/ApplicationEntrypoint.kt @@ -0,0 +1,22 @@ +package io.github.ermadmi78.kobby.server + +import io.ktor.server.engine.* +import io.ktor.server.netty.* +import java.net.ServerSocket + +class ApplicationEntrypoint { + fun createContext(port: Int? = null, wait: Boolean = false): NettyApplicationEngine { + // disable development mode + System.setProperty("io.ktor.development", "false") + val localPort = port ?: ServerSocket(0).use { it.localPort } + + println("connecting on port $localPort") + return embeddedServer(Netty, localPort) { + graphQLModule() + }.start(wait) + } +} + +fun main(args: Array) { + ApplicationEntrypoint().createContext(18080, true) +} diff --git a/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/GraphQLModule.kt b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/GraphQLModule.kt new file mode 100644 index 00000000..5f61bff1 --- /dev/null +++ b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/GraphQLModule.kt @@ -0,0 +1,70 @@ +package io.github.ermadmi78.kobby.server + + +import com.expediagroup.graphql.dataloader.KotlinDataLoaderRegistryFactory +import com.expediagroup.graphql.generator.hooks.FlowSubscriptionSchemaGeneratorHooks +import com.expediagroup.graphql.server.ktor.* +import graphql.scalars.ExtendedScalars +import graphql.schema.GraphQLType +import io.github.ermadmi78.kobby.server.controller.ActorQueryService +import io.github.ermadmi78.kobby.server.controller.CountryQueryService +import io.github.ermadmi78.kobby.server.controller.FilmQueryService +import io.github.ermadmi78.kobby.server.controller.MutationsService +import io.github.ermadmi78.kobby.server.controller.SubscriptionService +import io.github.ermadmi78.kobby.server.controller.TaggableQueryService +import io.github.ermadmi78.kobby.server.controller.graphqlIDType +import io.ktor.serialization.jackson.* +import io.ktor.server.application.* +import io.ktor.server.plugins.cors.routing.CORS +import io.ktor.server.plugins.statuspages.* +import io.ktor.server.routing.* +import io.ktor.server.websocket.* +import java.time.Duration +import java.time.LocalDate +import kotlin.reflect.KClass +import kotlin.reflect.KType + +fun Application.graphQLModule() { + install(WebSockets) { + pingPeriod = Duration.ofSeconds(1) + contentConverter = JacksonWebsocketContentConverter() + } + install(StatusPages) { + defaultGraphQLStatusPages() + } + install(CORS) { + anyHost() + } + install(GraphQL) { + schema { + packages = listOf("io.github.ermadmi78.kobby.server") + queries = listOf( + FilmQueryService(), ActorQueryService(), CountryQueryService(), TaggableQueryService() + ) + mutations = listOf(MutationsService()) + subscriptions = listOf(SubscriptionService()) + hooks = object : FlowSubscriptionSchemaGeneratorHooks() { + override fun willGenerateGraphQLType(type: KType): GraphQLType? = + when (type.classifier as? KClass<*>) { + Long::class -> graphqlIDType + Map::class -> ExtendedScalars.Json + LocalDate::class -> ExtendedScalars.Date + else -> null + } + } + } + engine { + dataLoaderRegistryFactory = KotlinDataLoaderRegistryFactory() + } + server { + contextFactory = DefaultKtorGraphQLContextFactory() + } + } + routing { + graphQLGetRoute() + graphQLPostRoute() + graphQLSubscriptionsRoute("graphql-ws") + graphiQLRoute() + graphQLSDLRoute() + } +} \ No newline at end of file diff --git a/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/WebServer.kt b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/WebServer.kt new file mode 100644 index 00000000..4a757d0d --- /dev/null +++ b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/WebServer.kt @@ -0,0 +1,24 @@ +package io.github.ermadmi78.kobby.server + +import io.ktor.server.netty.NettyApplicationEngine +import org.gradle.api.services.BuildService +import org.gradle.api.services.BuildServiceParameters + +abstract class WebServer : BuildService, AutoCloseable { + private lateinit var context: NettyApplicationEngine + + fun getServerUrl(): String { + if (!this::context.isInitialized) { + this.context = ApplicationEntrypoint().createContext() + } + + val port = context.environment.connectors.map { it.port }.first() + return "localhost:$port" + } + + override fun close() { + if (this::context.isInitialized) { + this.context.stop() + } + } +} \ No newline at end of file diff --git a/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/controller/ActorQueryService.kt b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/controller/ActorQueryService.kt new file mode 100644 index 00000000..77cc45d3 --- /dev/null +++ b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/controller/ActorQueryService.kt @@ -0,0 +1,23 @@ +package io.github.ermadmi78.kobby.server.controller + +import com.expediagroup.graphql.server.operations.Query +import io.github.ermadmi78.kobby.server.models.Actor +import io.github.ermadmi78.kobby.server.models.Actor.Companion.accepted +import io.github.ermadmi78.kobby.server.models.Film.Companion.accepted +import io.github.ermadmi78.kobby.server.models.Gender +import java.time.LocalDate + +class ActorQueryService : Query { + + suspend fun actor(id: Long): Actor? = Actor.get(id) + + suspend fun actors( + firstName: String? = null, + lastName: String? = null, + birthdayFrom: LocalDate? = null, + birthdayTo: LocalDate? = null, + gender: Gender? = null, + limit: Int? = null, + offset: Int? = null + ): List = Actor.all().accepted(firstName, lastName, birthdayFrom, birthdayTo, gender, limit, offset) +} diff --git a/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/controller/CountryQueryService.kt b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/controller/CountryQueryService.kt new file mode 100644 index 00000000..6a0f5429 --- /dev/null +++ b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/controller/CountryQueryService.kt @@ -0,0 +1,16 @@ +package io.github.ermadmi78.kobby.server.controller + +import com.expediagroup.graphql.server.operations.Query +import io.github.ermadmi78.kobby.server.models.Country +import io.github.ermadmi78.kobby.server.models.Country.Companion.accepted + +class CountryQueryService : Query { + + suspend fun country(id: Long): Country? = Country.get(id) + + suspend fun countries( + name: String? = null, + limit: Int? = null, + offset: Int? = null + ): List = Country.all().accepted(name, limit, offset) +} diff --git a/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/controller/FilmQueryService.kt b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/controller/FilmQueryService.kt new file mode 100644 index 00000000..464336d4 --- /dev/null +++ b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/controller/FilmQueryService.kt @@ -0,0 +1,18 @@ +package io.github.ermadmi78.kobby.server.controller + +import com.expediagroup.graphql.server.operations.Query +import io.github.ermadmi78.kobby.server.models.Film +import io.github.ermadmi78.kobby.server.models.Film.Companion.accepted +import io.github.ermadmi78.kobby.server.models.Genre + +class FilmQueryService : Query { + + suspend fun film(id: Long): Film? = Film.get(id) + + suspend fun films( + title: String? = null, + genre: Genre? = null, + limit: Int? = null, + offset: Int? = null + ): List = Film.all().accepted(title, genre, limit, offset) +} diff --git a/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/controller/IDScalar.kt b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/controller/IDScalar.kt new file mode 100644 index 00000000..7251ca3f --- /dev/null +++ b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/controller/IDScalar.kt @@ -0,0 +1,18 @@ +package io.github.ermadmi78.kobby.server.controller + +import graphql.language.StringValue +import graphql.schema.Coercing +import graphql.schema.GraphQLScalarType + +val graphqlIDType = GraphQLScalarType.newScalar() + .name("ID") + .description("A type representing a Long") + .coercing(IDCoercing as Coercing<*, *>) + .build() + +object IDCoercing : Coercing { + override fun parseValue(input: Any): Long = serialize(input).toLong() + override fun parseLiteral(input: Any): Long? = (input as? StringValue)?.value?.toLong() + override fun serialize(dataFetcherResult: Any): String = dataFetcherResult.toString() +} + diff --git a/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/controller/MutationsService.kt b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/controller/MutationsService.kt new file mode 100644 index 00000000..934161cc --- /dev/null +++ b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/controller/MutationsService.kt @@ -0,0 +1,55 @@ +package io.github.ermadmi78.kobby.server.controller + +import com.expediagroup.graphql.server.operations.Mutation +import io.github.ermadmi78.kobby.server.models.* +import java.time.LocalDate + +class MutationsService : Mutation { + suspend fun createCountry(name: String) = Country.create(name) + + suspend fun createActor(countryId: Long, actor: ActorInput, tags: TagInput? = null) = + Actor.create(countryId, actor, tags) + + suspend fun createFilm(countryId: Long, film: FilmInput, tags: TagInput? = null) = + Film.create(countryId, film, tags) + + suspend fun associate(filmId: Long, actorId: Long): Boolean { + val film = Film.get(filmId) ?: return false + val actor = Actor.get(actorId) ?: return false + + return if (film.actors.any { it.id == actor.id }) false else { + film.actors.add(actor) + true + } + } + + suspend fun tagFilm(filmId: Long, tagValue: String): Boolean { + val film = Film.get(filmId) ?: return false + return if (film.tags.any { it.value == tagValue }) false else { + film.tags.add(Tag(tagValue)) + true + } + } + + suspend fun tagActor(actorId: Long, tagValue: String): Boolean { + val actor = Actor.get(actorId) ?: return false + return if (actor.tags.any { it.value == tagValue }) false else { + actor.tags.add(Tag(tagValue)) + true + } + } + + suspend fun updateBirthday(actorId: Long, birthday: LocalDate): Actor? { + val actor = Actor.get(actorId) ?: return null + return actor.apply { + this.birthday = birthday + } + } + + suspend fun truncateMutations(): Boolean { + Actor.truncate() + Film.truncate() + Country.truncate() + return true + } +} diff --git a/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/controller/SubscriptionService.kt b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/controller/SubscriptionService.kt new file mode 100644 index 00000000..ab09ed7c --- /dev/null +++ b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/controller/SubscriptionService.kt @@ -0,0 +1,38 @@ +package io.github.ermadmi78.kobby.server.controller + +import com.expediagroup.graphql.server.operations.Subscription +import io.github.ermadmi78.kobby.server.models.Actor +import io.github.ermadmi78.kobby.server.models.Country +import io.github.ermadmi78.kobby.server.models.Film +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.filter + + +class SubscriptionService : Subscription { + + companion object { + private fun defaultFlow() = MutableSharedFlow( + extraBufferCapacity = 3, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + private val countriesFlow = defaultFlow() + private val filmsFlow = defaultFlow() + private val actorsFlow = defaultFlow() + + suspend fun emit(country: Country) = countriesFlow.emit(country) + suspend fun emit(film: Film) = filmsFlow.emit(film) + suspend fun emit(actor: Actor) = actorsFlow.emit(actor) + } + + fun countryCreated(): Flow = countriesFlow.asSharedFlow() + + fun filmCreated(countryId: Long? = null): Flow = filmsFlow.asSharedFlow() + .filter { countryId == null || it.countryId == countryId } + + fun actorCreated(countryId: Long? = null): Flow = actorsFlow.asSharedFlow() + .filter { countryId == null || it.countryId == countryId } +} diff --git a/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/controller/TaggableQueryService.kt b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/controller/TaggableQueryService.kt new file mode 100644 index 00000000..8907bc78 --- /dev/null +++ b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/controller/TaggableQueryService.kt @@ -0,0 +1,14 @@ +package io.github.ermadmi78.kobby.server.controller + +import com.expediagroup.graphql.server.operations.Query +import io.github.ermadmi78.kobby.server.models.Actor +import io.github.ermadmi78.kobby.server.models.Film +import io.github.ermadmi78.kobby.server.models.Taggable + +class TaggableQueryService : Query { + + suspend fun taggable(tag: String): List = buildList { + addAll(Film.all().filter { it.containsTag(tag) }) + addAll(Actor.all().filter { it.containsTag(tag) }) + } +} diff --git a/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/models/Actor.kt b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/models/Actor.kt new file mode 100644 index 00000000..6840a343 --- /dev/null +++ b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/models/Actor.kt @@ -0,0 +1,98 @@ +package io.github.ermadmi78.kobby.server.models + +import io.github.ermadmi78.kobby.server.controller.SubscriptionService.Companion.emit +import io.github.ermadmi78.kobby.server.models.Film.Companion.accepted +import io.github.ermadmi78.kobby.server.models.asLimit +import java.time.LocalDate +import java.time.LocalDate.of + +data class Actor( + override val id: Long, + override val tags: MutableList, + val firstName: String, + val lastName: String?, + var birthday: LocalDate, + val gender: Gender, + val countryId: Long +) : Entity, Taggable { + companion object { + private fun initial() = mutableListOf( + Actor(id = 1, countryId = 8, firstName = "Audrey", lastName = "Tautou", birthday = of(1976, 8, 9), gender = Gender.FEMALE, tags = mutableListOf(Tag("best"), Tag("audrey"))), + Actor(id = 2, countryId = 8, firstName = "Mathieu", lastName = "Kassovitz", birthday = of(1967, 8, 3), gender = Gender.MALE, tags = mutableListOf()), + Actor(id = 3, countryId = 8, firstName = "Jamel", lastName = "Debbouze", birthday = of(1975, 6, 18), gender = Gender.MALE, tags = mutableListOf(Tag("best"))), + Actor(id = 4, countryId = 8, firstName = "Dominique", lastName = "Pinon", birthday = of(1955, 3, 4), gender = Gender.MALE, tags = mutableListOf()), + Actor(id = 5, countryId = 8, firstName = "Gaspard", lastName = "Ulliel", birthday = of(1984, 11, 25),gender = Gender.MALE, tags = mutableListOf()), + Actor(id = 6, countryId = 8, firstName = "Guillaume", lastName = "Canet", birthday = of(1973, 4, 10), gender = Gender.MALE, tags = mutableListOf()), + Actor(id = 7, countryId = 8, firstName = "Gad", lastName = "Elmaleh", birthday = of(1971, 4, 19), gender = Gender.MALE, tags = mutableListOf()), + Actor(id = 8, countryId = 18, firstName = "Hugh", lastName = "Laurie", birthday = of(1959, 6, 11), gender = Gender.MALE, tags = mutableListOf(Tag("best"), Tag("house"))), + Actor(id = 9, countryId = 18, firstName = "Stephen", lastName = "Fry", birthday = of(1957, 8, 24), gender = Gender.MALE, tags = mutableListOf(Tag("best"))), + Actor(id = 10, countryId = 19, firstName = "Keanu", lastName = "Reeves", birthday = of(1964, 9, 2), gender = Gender.MALE, tags = mutableListOf()), + Actor(id = 11, countryId = 19, firstName = "Julia", lastName = "Roberts", birthday = of(1967, 10, 28),gender = Gender.FEMALE, tags = mutableListOf(Tag("best"), Tag("julia"))), + Actor(id = 12, countryId = 19, firstName = "George", lastName = "Clooney", birthday = of(1967, 10, 28),gender = Gender.MALE, tags = mutableListOf(Tag("best"), Tag("clooney"))), + Actor(id = 13, countryId = 19, firstName = "Brad", lastName = "Pitt", birthday = of(1963, 12, 18),gender = Gender.MALE, tags = mutableListOf()), + Actor(id = 14, countryId = 19, firstName = "Susan", lastName = "Sarandon", birthday = of(1946, 10, 4), gender = Gender.FEMALE, tags = mutableListOf()), + Actor(id = 15, countryId = 19, firstName = "Richard", lastName = "Gere", birthday = of(1949, 8, 31), gender = Gender.MALE, tags = mutableListOf()), + Actor(id = 16, countryId = 19, firstName = "Salma", lastName = "Hayek", birthday = of(1966, 9, 2), gender = Gender.FEMALE, tags = mutableListOf()) + ) + + private var actors = initial() + fun truncate() { this.actors = initial() } + fun all(): List = actors + fun get(id: Long): Actor? = actors.firstOrNull { it.id == id } + suspend fun create(countryId: Long, actor: ActorInput, tags: TagInput? = null): Actor { + val maxId = actors.maxOfOrNull { it.id } ?: 0 + return Actor( + maxId.inc(), + tags?.let { mutableListOf(Tag(it.value)) } ?: mutableListOf(), + actor.firstName, + actor.lastName, + actor.birthday, + actor.gender, + countryId + ).also { + actors.add(it) + emit(it) + } + } + + fun List.accepted( + firstName: String? = null, + lastName: String? = null, + birthdayFrom: LocalDate? = null, + birthdayTo: LocalDate? = null, + gender: Gender? = null, + limit: Int? = null, + offset: Int? = null + ): List = filter { firstName == null || it.firstName.contains(firstName, true) } + .filter { lastName == null || it.lastName?.contains(lastName, true) == true } + .filter { gender == null || it.gender == gender } + .filter { birthdayFrom == null || it.birthday > birthdayFrom } + .filter { birthdayTo == null || it.birthday < birthdayTo } + .drop(offset ?: 0) + .take(limit.asLimit()) + } + + override suspend fun fields(keys: List?): Map { + return buildMap { + (keys?.toSet() ?: setOf("id", "firstName", "lastName", "birthday", "gender")).forEach { + when (it) { + "id" -> put(it, id) + "firstName" -> put(it, firstName) + "lastName" -> put(it, lastName ?: "null") + "birthday" -> put(it, birthday.toString()) + "gender" -> put(it, gender.name) + } + } + } + } + + suspend fun country(): Country = Country.get(countryId)!! + + suspend fun films( + title: String? = null, + genre: Genre? = null, + limit: Int? = null, + offset: Int? = null + ): List = Film.all().filter { film -> film.actors.any { it.id == this.id } } + .accepted(title, genre, limit, offset) +} \ No newline at end of file diff --git a/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/models/ActorInput.kt b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/models/ActorInput.kt new file mode 100644 index 00000000..8cd9d87d --- /dev/null +++ b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/models/ActorInput.kt @@ -0,0 +1,10 @@ +package io.github.ermadmi78.kobby.server.models + +import java.time.LocalDate + +data class ActorInput( + val firstName: String, + val lastName: String?, + val birthday: LocalDate, + val gender: Gender, +) diff --git a/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/models/Country.kt b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/models/Country.kt new file mode 100644 index 00000000..dd9d18d2 --- /dev/null +++ b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/models/Country.kt @@ -0,0 +1,98 @@ +package io.github.ermadmi78.kobby.server.models + +import com.expediagroup.graphql.generator.annotations.GraphQLUnion +import io.github.ermadmi78.kobby.server.controller.SubscriptionService.Companion.emit +import io.github.ermadmi78.kobby.server.models.Actor.Companion.accepted +import io.github.ermadmi78.kobby.server.models.Film.Companion.accepted +import io.github.ermadmi78.kobby.server.models.asLimit +import java.time.LocalDate + +data class Country( + override val id: Long, + val name: String +) : Entity { + companion object { + private fun initial() = mutableListOf( + Country(1, name = "Argentina"), + Country(2, name = "Australia"), + Country(3, name = "Austria"), + Country(4, name = "Belgium"), + Country(5, name = "Brazil"), + Country(6, name = "Canada"), + Country(7, name = "Finland"), + Country(8, name = "France"), + Country(9, name = "Germany"), + Country(10, name = "Italy"), + Country(11, name = "Japan"), + Country(12, name = "New Zealand"), + Country(13, name = "Norway"), + Country(14, name = "Portugal"), + Country(15, name = "Russia"), + Country(16, name = "Spain"), + Country(17, name = "Sweden"), + Country(18, name = "United Kingdom"), + Country(19, name = "USA"), + ) + + private var countries = initial() + fun truncate() { this.countries = initial() } + fun all(): List = countries + fun get(id: Long): Country? = countries.firstOrNull { it.id == id } + suspend fun create(name: String): Country { + val maxId = countries.maxOfOrNull { it.id } ?: 0 + return Country(maxId.inc(), name).also { + countries.add(it) + emit(it) + } + } + + fun List.accepted( + name: String? = null, + limit: Int? = null, + offset: Int? = null, + ): List = filter { name == null || it.name.contains(name, true) } + .drop(offset ?: 0) + .take(limit.asLimit()) + } + + suspend fun film(id: Long): Film? = films().firstOrNull { it.id == id } + suspend fun films( + title: String? = null, + genre: Genre? = null, + limit: Int? = null, + offset: Int? = null, + ): List = Film.all().filter { it.countryId == id } + .accepted(title, genre, limit, offset) + + suspend fun actor(id: Long): Actor? = films().flatMap { film -> film.actors.filter { it.id == id } }.firstOrNull() + suspend fun actors( + firstName: String? = null, + lastName: String? = null, + birthdayFrom: LocalDate? = null, + birthdayTo: LocalDate? = null, + gender: Gender? = null, + limit: Int? = null, + offset: Int? = null + ): List = Actor.all().filter { it.countryId == id } + .accepted(firstName, lastName, birthdayTo, birthdayFrom, gender, limit, offset) + + override suspend fun fields(keys: List?): Map { + return buildMap { + (keys?.toSet() ?: setOf("id", "name")).forEach { + when (it) { + "id" -> put(it, id) + "name" -> put(it, name) + } + } + } + } + + @GraphQLUnion( + name = "Native", + possibleTypes = [Film::class, Actor::class] + ) + suspend fun native(): List = buildList { + addAll(films()) + addAll(actors()) + } +} diff --git a/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/models/Entity.kt b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/models/Entity.kt new file mode 100644 index 00000000..baaa39f5 --- /dev/null +++ b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/models/Entity.kt @@ -0,0 +1,7 @@ +package io.github.ermadmi78.kobby.server.models + +interface Entity { + val id: Long + suspend fun fields(keys: List? = null): Map +} + diff --git a/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/models/Film.kt b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/models/Film.kt new file mode 100644 index 00000000..39d589de --- /dev/null +++ b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/models/Film.kt @@ -0,0 +1,94 @@ +package io.github.ermadmi78.kobby.server.models + +import com.expediagroup.graphql.generator.annotations.GraphQLIgnore +import io.github.ermadmi78.kobby.server.controller.SubscriptionService.Companion.emit +import io.github.ermadmi78.kobby.server.models.Actor.Companion.accepted +import io.github.ermadmi78.kobby.server.models.asLimit +import java.time.LocalDate + +data class Film( + override val id: Long, + override val tags: MutableList, + val title: String, + val genre: Genre, + val countryId: Long, + + @GraphQLIgnore + val actors: MutableList, +): Entity, Taggable { + companion object { + + private fun actors(vararg ids: Long): MutableList = ids + .map { Actor.get(it) } + .filterNotNull() + .toMutableList() + + private fun initial() = mutableListOf( + Film(1, title = "Amelie", countryId = 8, genre = Genre.COMEDY, tags = mutableListOf(Tag("best"), Tag("audrey")), actors = actors(1, 2, 3, 4)), + Film(2, title = "A Very Long Engagement", countryId = 8, genre = Genre.DRAMA, tags = mutableListOf(Tag("audrey")), actors = actors(1, 4, 5)), + Film(3, title = "Hunting and Gathering", countryId = 8, genre = Genre.DRAMA, tags = mutableListOf(Tag("audrey")), actors = actors(1, 6)), + Film(4, title = "Priceless", countryId = 8, genre = Genre.COMEDY, tags = mutableListOf(Tag("best"), Tag("house")), actors = actors(1, 7)), + Film(5, title = "House", countryId = 18, genre = Genre.COMEDY, tags = mutableListOf(Tag("best"), Tag("house")), actors = actors(8)), + Film(6, title = "Peter's Friends", countryId = 18, genre = Genre.COMEDY, tags = mutableListOf(Tag("house")), actors = actors(8, 9)), + Film(7, title = "Street Kings", countryId = 18, genre = Genre.THRILLER, tags = mutableListOf(Tag("house")), actors = actors(8, 10)), + Film(8, title = "Mr. Pip", countryId = 18, genre = Genre.DRAMA, tags = mutableListOf(Tag("house")), actors = actors(8)), + Film(9, title = "Ocean's Eleven", countryId = 19, genre = Genre.THRILLER, tags = mutableListOf(Tag("best"), Tag("julia"), Tag("clooney")), actors = actors(11, 12, 13)), + Film(10, title = "Stepmom", countryId = 19, genre = Genre.DRAMA, tags = mutableListOf(Tag("julia")), actors = actors(11, 14)), + Film(11, title = "Pretty Woman", countryId = 19, genre = Genre.COMEDY, tags = mutableListOf(Tag("julia")), actors = actors(11, 15)), + Film(12, title = "From Dusk Till Dawn", countryId = 19, genre = Genre.THRILLER, tags = mutableListOf(Tag("clooney")), actors = actors(12, 16)), + ) + + private var films = initial() + fun truncate() { this.films = initial() } + fun all(): List = films + fun get(id: Long): Film? = films.firstOrNull { it.id == id } + + suspend fun create(countryId: Long, film: FilmInput, tags: TagInput? = null): Film { + val maxId = films.maxOfOrNull { it.id } ?: 0 + return Film( + maxId.inc(), + tags?.let { mutableListOf(Tag(it.value)) } ?: mutableListOf(), + film.title, + film.genre ?: Genre.DRAMA, + countryId, + mutableListOf() + ).also { + films.add(it) + emit(it) + } + } + + suspend fun List.accepted( + title: String? = null, + genre: Genre? = null, + limit: Int?, + offset: Int? + ): List = filter { title == null || it.title.contains(title, true) } + .filter { genre == null || it.genre == genre } + .drop(offset ?: 0) + .take((limit.asLimit())) + } + + override suspend fun fields(keys: List?): Map { + return buildMap { + (keys?.toSet() ?: setOf("id", "title", "genre")).forEach { + when (it) { + "id" -> put(it, id) + "title" -> put(it, title) + "genre" -> put(it, genre.name) + } + } + } + } + + suspend fun country(): Country = Country.get(countryId)!! + suspend fun actors( + firstName: String? = null, + lastName: String? = null, + birthdayFrom: LocalDate? = null, + birthdayTo: LocalDate? = null, + gender: Gender? = null, + limit: Int? = null, + offset: Int? = null + ): List = actors.accepted(firstName, lastName, birthdayFrom, birthdayTo, gender, limit, offset) +} diff --git a/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/models/FilmInput.kt b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/models/FilmInput.kt new file mode 100644 index 00000000..f681cdc2 --- /dev/null +++ b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/models/FilmInput.kt @@ -0,0 +1,3 @@ +package io.github.ermadmi78.kobby.server.models + +data class FilmInput(val title: String, val genre: Genre? = null) diff --git a/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/models/Gender.kt b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/models/Gender.kt new file mode 100644 index 00000000..abc3a408 --- /dev/null +++ b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/models/Gender.kt @@ -0,0 +1,6 @@ +package io.github.ermadmi78.kobby.server.models + +enum class Gender { + MALE, + FEMALE, +} diff --git a/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/models/Genre.kt b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/models/Genre.kt new file mode 100644 index 00000000..d826a6a5 --- /dev/null +++ b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/models/Genre.kt @@ -0,0 +1,20 @@ +package io.github.ermadmi78.kobby.server.models + +enum class Genre { + /** + * Drama + */ + DRAMA, + /** + * Comedy + */ + COMEDY, + /** + * Thriller + */ + THRILLER, + /** + * Horror + */ + HORROR, +} diff --git a/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/models/LimitUtil.kt b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/models/LimitUtil.kt new file mode 100644 index 00000000..55a1d9ff --- /dev/null +++ b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/models/LimitUtil.kt @@ -0,0 +1,6 @@ +package io.github.ermadmi78.kobby.server.models + +fun Int?.asLimit(): Int = when (val limit = this) { + null -> 10 + else -> if (limit < 0) Int.MAX_VALUE else limit +} diff --git a/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/models/Tag.kt b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/models/Tag.kt new file mode 100644 index 00000000..e4754c0c --- /dev/null +++ b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/models/Tag.kt @@ -0,0 +1,5 @@ +package io.github.ermadmi78.kobby.server.models + +data class Tag( + val `value`: String? = null, +) diff --git a/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/models/TagInput.kt b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/models/TagInput.kt new file mode 100644 index 00000000..9facd41a --- /dev/null +++ b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/models/TagInput.kt @@ -0,0 +1,3 @@ +package io.github.ermadmi78.kobby.server.models + +data class TagInput(val value: String) diff --git a/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/models/Taggable.kt b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/models/Taggable.kt new file mode 100644 index 00000000..bc7a273c --- /dev/null +++ b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/server/models/Taggable.kt @@ -0,0 +1,10 @@ +package io.github.ermadmi78.kobby.server.models + +import com.expediagroup.graphql.generator.annotations.GraphQLIgnore + +interface Taggable : Entity { + val tags: List + + @GraphQLIgnore + fun containsTag(tag: String): Boolean = tags.any { it.value != null && it.value == tag } +} diff --git a/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/tests/kobby-conventions.gradle.kts b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/tests/kobby-conventions.gradle.kts new file mode 100644 index 00000000..fbc6c739 --- /dev/null +++ b/kobby-gradle-tests/buildSrc/src/main/kotlin/io/github/ermadmi78/kobby/tests/kobby-conventions.gradle.kts @@ -0,0 +1,38 @@ +import io.github.ermadmi78.kobby.server.WebServer +import org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL +import java.nio.file.Paths +import java.util.Properties +import kotlin.apply + +plugins { + kotlin("jvm") + id("com.adarshr.test-logger") + id("io.github.ermadmi78.kobby") +} + +repositories { + mavenLocal() + mavenCentral() +} + +tasks { + test { + testLogging.showStandardStreams = true + testLogging.exceptionFormat = FULL + useJUnitPlatform() + } +} + +dependencies { + testImplementation(testLibs.bundles.slf4j) + testImplementation(testLibs.bundles.kotest) +} + +val sharedServerServiceProvider = gradle.sharedServices.registerIfAbsent("sharedServer", WebServer::class.java) + +tasks.named("test", Test::class.java) { + usesService(sharedServerServiceProvider) + + val serverUrl = sharedServerServiceProvider.get().getServerUrl() + systemProperty("kobby-gradle-tests.server-url", serverUrl) +} diff --git a/kobby-gradle-tests/cinema-jackson-composite/build.gradle.kts b/kobby-gradle-tests/cinema-jackson-composite/build.gradle.kts new file mode 100644 index 00000000..d9b43902 --- /dev/null +++ b/kobby-gradle-tests/cinema-jackson-composite/build.gradle.kts @@ -0,0 +1,25 @@ +plugins { + `kobby-conventions` +} + +kobby { + kotlin { + scalars = mapOf( + "Date" to typeOf("java.time", "LocalDate"), + "JSON" to typeMap.parameterize(typeString, typeAny.nullable()) + ) + + adapter { + ktor { + compositeEnabled = true + } + } + } +} + +dependencies { + implementation(testLibs.ktor.cio) + implementation(testLibs.ktor.content.negotiation) + implementation(testLibs.ktor.serialization.jackson) + implementation(testLibs.jackson.annotations) +} diff --git a/kobby-gradle-tests/cinema-jackson-composite/src/main/resources/cinema.graphqls b/kobby-gradle-tests/cinema-jackson-composite/src/main/resources/cinema.graphqls new file mode 100644 index 00000000..3e3fe1f9 --- /dev/null +++ b/kobby-gradle-tests/cinema-jackson-composite/src/main/resources/cinema.graphqls @@ -0,0 +1,472 @@ +directive @primaryKey on FIELD_DEFINITION +directive @required on FIELD_DEFINITION +directive @default on FIELD_DEFINITION +directive @selection on FIELD_DEFINITION + +scalar JSON +scalar Date + +schema { + query: Query + mutation: Mutation + subscription: Subscription +} + +type Query { + """ + Find country by id. + Returns null if not found. + """ + country(id: ID!): Country + + """ + Find countries by name. + Returns empty list if not found. + """ + countries( + """Part of name of country to search %""" + name: String, + + """ + Limit of result list. + Put -1 to be unlimited. + """ + limit: Int! = 10, + + """Offset of result list.""" + offset: Int! = 0 + ): [Country!]! + + """ + Find film by id. + Returns null if not found. + """ + film(id: ID!): Film + + """ + Find films by title and genre. + Returns empty list if not found. + """ + films( + """Part of title of film to search""" + title: String, + + """Genre of film to search""" + genre: Genre, + + """ + Limit of result list. + Put -1 to be unlimited. + """ + limit: Int! = 10, + + """Offset of result list.""" + offset: Int! = 0 + ): [Film!]! @selection + + """ + Find actor by id. + Returns null if not found. + """ + actor(id: ID!): Actor + + """ + Find actors by firstName, lastName, birthday and gender. + Returns empty list if not found. + """ + actors( + """Part of first name of actor to search""" + firstName: String, + + """Part of last name of actor to search""" + lastName: String, + + """Find actors whose birthday is greater than or equal to birthdayFrom""" + birthdayFrom: Date, + + """Find actors whose birthday is less than or equal to birthdayTo""" + birthdayTo: Date, + + """Gender of actor to search""" + gender: Gender, + + """ + Limit of result list. + Put -1 to be unlimited. + """ + limit: Int! = 10, + + """Offset of result list.""" + offset: Int! = 0 + ): [Actor!]! @selection + + """Find entities by tag""" + taggable(tag: String!): [Taggable!]! +} + +type Mutation { + """Create country""" + createCountry( + """Name of the country""" + name: String! + ): Country! + + """Create film""" + createFilm( + """ID of the country to which the film belongs""" + countryId: ID!, + + """Film input data""" + film: FilmInput!, + + """Add tag to film if tag is not null""" + tags: TagInput + ): Film! @selection + + """Create actor""" + createActor( + """ID of the country to which the actor belongs""" + countryId: ID!, + + """Actor input data""" + actor: ActorInput!, + + """Add tag to actor if tag is not null""" + tags: TagInput + ): Actor! @selection + + """Associate film with actor""" + associate( + """ID of film %""" + filmId: ID!, + + """ID of actor""" + actorId: ID! + ): Boolean! + + """ + Add tag to film. + Returns false if the film already had such a tag + """ + tagFilm( + """ID of film""" + filmId: ID!, + + """Tag value""" + tagValue: String! + ): Boolean! + + """ + Add tag to actor. + Returns false if the actor already had such a tag + """ + tagActor( + """ID of actor""" + actorId: ID!, + + """Tag value""" + tagValue: String! + ): Boolean! +} + +type Subscription { + """Listen new countries""" + countryCreated: Country! + + """Listen new films""" + filmCreated(countryId: ID): Film! + + """Listen new actors""" + actorCreated(countryId: ID): Actor! +} + +"""Base interface for all entities. %""" +interface Entity { + """Unique identifier of entity %""" + id: ID! @primaryKey + + """ + Fields of entity in key-value map. + Introduced for testing complex scalars. + """ + fields( + """ + List of field keys to load. % + If no keys specified all entity fields will be loaded. + """ + keys: [String!] + ): JSON! @selection +} + +""" +Entity with tags. +Introduced for testing complex inheritance. +""" +interface Taggable implements Entity { + id: ID! + fields(keys: [String!]): JSON! + + """Tags of entity""" + tags: [Tag!]! +} + +""" +Country entity. +The country can be home to several films and several actors. % +""" +type Country implements Entity { + id: ID! + fields(keys: [String!]): JSON! + + """The name of the country %""" + name: String! @default + + """ + Find film by id. + Returns null if not found. + """ + film(id: ID!): Film + + """ + Find films of country by title and genre. + Returns empty list if not found. + """ + films( + """Part of title of film to search %""" + title: String, + + """Genre of film to search %""" + genre: Genre, + + """ + Limit of result list. + Put -1 to be unlimited. + """ + limit: Int! = 10, + + """Offset of result list.""" + offset: Int! = 0 + ): [Film!]! @selection + + """ + Find actor by id. + Returns null if not found. + """ + actor(id: ID!): Actor + + """ + Find actors of country by firstName, lastName, birthday and gender. + Returns empty list if not found. + """ + actors( + """Part of first name of actor to search""" + firstName: String, + + """Part of last name of actor to search""" + lastName: String, + + """Find actors whose birthday is greater than or equal to birthdayFrom""" + birthdayFrom: Date, + + """Find actors whose birthday is less than or equal to birthdayTo""" + birthdayTo: Date, + + """Gender of actor to search""" + gender: Gender, + + """ + Limit of result list. + Put -1 to be unlimited. + """ + limit: Int! = 10, + + """Offset of result list.""" + offset: Int! = 0 + ): [Actor!]! @selection + + """Find entities of this country by tag""" + taggable(tag: String!): [Taggable!]! + + """Find native entities of this country""" + native: [Native!]! +} + +""" +Film entity. +The film belongs to one country and can be played by several actors. +""" +type Film implements Entity & Taggable { + id: ID! + fields(keys: [String!]): JSON! + tags: [Tag!]! + + """Title of film""" + title: String! @default + + """Genre of film.""" + genre: Genre! + + """ID of the country to which the film belongs""" + countryId: ID! @required + + """The country to which the film belongs""" + country: Country! + + """ + Find actors of film by firstName, lastName, birthday and gender. + Returns empty list if not found. + """ + actors( + """Part of first name of actor to search""" + firstName: String, + + """Part of last name of actor to search""" + lastName: String, + + """Find actors whose birthday is greater than or equal to birthdayFrom""" + birthdayFrom: Date, + + """Find actors whose birthday is less than or equal to birthdayTo""" + birthdayTo: Date, + + """Gender of actor to search""" + gender: Gender, + + """ + Limit of result list. + Put -1 to be unlimited. + """ + limit: Int! = 10, + + """Offset of result list.""" + offset: Int! = 0 + ): [Actor!]! @selection +} + +"""Enum of available film genres %""" +enum Genre { + """Drama %""" + DRAMA + + """Comedy""" + COMEDY + + """Thriller""" + THRILLER + + """Horror""" + HORROR +} + +"""Film input data %""" +input FilmInput { + """Title of film %""" + title: String! + + """Genre of film""" + genre: Genre! = DRAMA +} + +""" +Actor entity. +The actor belongs to one country and can play in several films. +""" +type Actor implements Entity & Taggable { + """Unique identifier of actor""" + id: ID! + fields(keys: [String!]): JSON! + tags: [Tag!]! + + """ + First name of actor. + I assume all actors have a first name - so field is not null. + """ + firstName: String! @default + + """ + Surname of the actor. + This field is nullable because the actor can use an alias and not have a last name. + """ + lastName: String @default + + """Actor's birthday""" + birthday: Date! @required + + """Actor's gender""" + gender: Gender! + + """ + ID of the country to which the actor belongs + (@primaryKey added to test complex primary keys) + """ + countryId: ID! @primaryKey + + """The country to which the actor belongs""" + country: Country! + + """ + Find films of actor by title and genre. + Returns empty list if not found. + """ + films( + """Part of title of film to search""" + title: String, + + """Genre of film to search""" + genre: Genre, + + """ + Limit of result list. + Put -1 to be unlimited. + """ + limit: Int! = 10, + + """Offset of result list.""" + offset: Int! = 0 + ): [Film!]! @selection +} + +"""The gender of the actor""" +enum Gender { + MALE + FEMALE +} + +"""Actor input data""" +input ActorInput { + """First name of actor.""" + firstName: String! + + """ + Surname of the actor. + This field is nullable because the actor can use an alias and not have a last name. + """ + lastName: String + + """Actor's birthday""" + birthday: Date! + + """Actor's gender""" + gender: Gender! +} + +""" +Type Tag is introduced for testing types with single value. +See class TagDto. +""" +type Tag { + """The tag value""" + value: String! +} + +""" +Input TagInput is introduced for testing inputs with single value. +See class TagInput. +""" +input TagInput { + """The tag value""" + value: String! +} + +"""Union of natives %""" +union Native = Actor | Film \ No newline at end of file diff --git a/kobby-gradle-tests/cinema-jackson-composite/src/test/kotlin/PluginTest.kt b/kobby-gradle-tests/cinema-jackson-composite/src/test/kotlin/PluginTest.kt new file mode 100644 index 00000000..4bcf5a6f --- /dev/null +++ b/kobby-gradle-tests/cinema-jackson-composite/src/test/kotlin/PluginTest.kt @@ -0,0 +1,60 @@ +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import io.kotest.core.spec.style.AnnotationSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.serialization.jackson.* +import kobby.kotlin.CinemaContext +import kobby.kotlin.CinemaMapper +import kobby.kotlin.adapter.ktor.CinemaCompositeKtorAdapter +import kobby.kotlin.cinemaContextOf +import kotlinx.coroutines.runBlocking +import kotlin.reflect.KClass + +class PluginTest : AnnotationSpec() { + + private fun schema(): CinemaContext { + val client = HttpClient(CIO) { + install(ContentNegotiation) { + jackson() + } + } + + val mapper = object : CinemaMapper { + private val objectMapper = jacksonObjectMapper() + override fun serialize(value: Any): String = objectMapper.writeValueAsString(value) + + override fun deserialize( + content: String, + contentType: KClass + ): T = objectMapper.readValue(content, contentType.java) + } + + val targetHost = System.getProperty("kobby-gradle-tests.server-url") + checkNotNull(targetHost) { + "webserver doesn't provide server url: check your gradle configuration" + } + + return cinemaContextOf( + CinemaCompositeKtorAdapter( + client, + "http://$targetHost/graphql", + "ws://$targetHost/graphql-ws", + mapper + ) + ) + } + + @Test + suspend fun `query request`() { + val result = schema().query { + films { + limit = 5 + title + } + } + + result.films shouldHaveSize 5 + } +} \ No newline at end of file diff --git a/kobby-gradle-tests/cinema-jackson-simple/build.gradle.kts b/kobby-gradle-tests/cinema-jackson-simple/build.gradle.kts new file mode 100644 index 00000000..6b42d591 --- /dev/null +++ b/kobby-gradle-tests/cinema-jackson-simple/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + `kobby-conventions` +} + +kobby { + kotlin { + scalars = mapOf( + "Date" to typeOf("java.time", "LocalDate"), + "JSON" to typeMap.parameterize(typeString, typeAny.nullable()) + ) + } +} + +dependencies { + implementation(testLibs.ktor.cio) + implementation(testLibs.ktor.content.negotiation) + implementation(testLibs.ktor.serialization.jackson) + implementation(testLibs.jackson.annotations) +} diff --git a/kobby-gradle-tests/cinema-jackson-simple/src/main/resources/cinema.graphqls b/kobby-gradle-tests/cinema-jackson-simple/src/main/resources/cinema.graphqls new file mode 100644 index 00000000..3e3fe1f9 --- /dev/null +++ b/kobby-gradle-tests/cinema-jackson-simple/src/main/resources/cinema.graphqls @@ -0,0 +1,472 @@ +directive @primaryKey on FIELD_DEFINITION +directive @required on FIELD_DEFINITION +directive @default on FIELD_DEFINITION +directive @selection on FIELD_DEFINITION + +scalar JSON +scalar Date + +schema { + query: Query + mutation: Mutation + subscription: Subscription +} + +type Query { + """ + Find country by id. + Returns null if not found. + """ + country(id: ID!): Country + + """ + Find countries by name. + Returns empty list if not found. + """ + countries( + """Part of name of country to search %""" + name: String, + + """ + Limit of result list. + Put -1 to be unlimited. + """ + limit: Int! = 10, + + """Offset of result list.""" + offset: Int! = 0 + ): [Country!]! + + """ + Find film by id. + Returns null if not found. + """ + film(id: ID!): Film + + """ + Find films by title and genre. + Returns empty list if not found. + """ + films( + """Part of title of film to search""" + title: String, + + """Genre of film to search""" + genre: Genre, + + """ + Limit of result list. + Put -1 to be unlimited. + """ + limit: Int! = 10, + + """Offset of result list.""" + offset: Int! = 0 + ): [Film!]! @selection + + """ + Find actor by id. + Returns null if not found. + """ + actor(id: ID!): Actor + + """ + Find actors by firstName, lastName, birthday and gender. + Returns empty list if not found. + """ + actors( + """Part of first name of actor to search""" + firstName: String, + + """Part of last name of actor to search""" + lastName: String, + + """Find actors whose birthday is greater than or equal to birthdayFrom""" + birthdayFrom: Date, + + """Find actors whose birthday is less than or equal to birthdayTo""" + birthdayTo: Date, + + """Gender of actor to search""" + gender: Gender, + + """ + Limit of result list. + Put -1 to be unlimited. + """ + limit: Int! = 10, + + """Offset of result list.""" + offset: Int! = 0 + ): [Actor!]! @selection + + """Find entities by tag""" + taggable(tag: String!): [Taggable!]! +} + +type Mutation { + """Create country""" + createCountry( + """Name of the country""" + name: String! + ): Country! + + """Create film""" + createFilm( + """ID of the country to which the film belongs""" + countryId: ID!, + + """Film input data""" + film: FilmInput!, + + """Add tag to film if tag is not null""" + tags: TagInput + ): Film! @selection + + """Create actor""" + createActor( + """ID of the country to which the actor belongs""" + countryId: ID!, + + """Actor input data""" + actor: ActorInput!, + + """Add tag to actor if tag is not null""" + tags: TagInput + ): Actor! @selection + + """Associate film with actor""" + associate( + """ID of film %""" + filmId: ID!, + + """ID of actor""" + actorId: ID! + ): Boolean! + + """ + Add tag to film. + Returns false if the film already had such a tag + """ + tagFilm( + """ID of film""" + filmId: ID!, + + """Tag value""" + tagValue: String! + ): Boolean! + + """ + Add tag to actor. + Returns false if the actor already had such a tag + """ + tagActor( + """ID of actor""" + actorId: ID!, + + """Tag value""" + tagValue: String! + ): Boolean! +} + +type Subscription { + """Listen new countries""" + countryCreated: Country! + + """Listen new films""" + filmCreated(countryId: ID): Film! + + """Listen new actors""" + actorCreated(countryId: ID): Actor! +} + +"""Base interface for all entities. %""" +interface Entity { + """Unique identifier of entity %""" + id: ID! @primaryKey + + """ + Fields of entity in key-value map. + Introduced for testing complex scalars. + """ + fields( + """ + List of field keys to load. % + If no keys specified all entity fields will be loaded. + """ + keys: [String!] + ): JSON! @selection +} + +""" +Entity with tags. +Introduced for testing complex inheritance. +""" +interface Taggable implements Entity { + id: ID! + fields(keys: [String!]): JSON! + + """Tags of entity""" + tags: [Tag!]! +} + +""" +Country entity. +The country can be home to several films and several actors. % +""" +type Country implements Entity { + id: ID! + fields(keys: [String!]): JSON! + + """The name of the country %""" + name: String! @default + + """ + Find film by id. + Returns null if not found. + """ + film(id: ID!): Film + + """ + Find films of country by title and genre. + Returns empty list if not found. + """ + films( + """Part of title of film to search %""" + title: String, + + """Genre of film to search %""" + genre: Genre, + + """ + Limit of result list. + Put -1 to be unlimited. + """ + limit: Int! = 10, + + """Offset of result list.""" + offset: Int! = 0 + ): [Film!]! @selection + + """ + Find actor by id. + Returns null if not found. + """ + actor(id: ID!): Actor + + """ + Find actors of country by firstName, lastName, birthday and gender. + Returns empty list if not found. + """ + actors( + """Part of first name of actor to search""" + firstName: String, + + """Part of last name of actor to search""" + lastName: String, + + """Find actors whose birthday is greater than or equal to birthdayFrom""" + birthdayFrom: Date, + + """Find actors whose birthday is less than or equal to birthdayTo""" + birthdayTo: Date, + + """Gender of actor to search""" + gender: Gender, + + """ + Limit of result list. + Put -1 to be unlimited. + """ + limit: Int! = 10, + + """Offset of result list.""" + offset: Int! = 0 + ): [Actor!]! @selection + + """Find entities of this country by tag""" + taggable(tag: String!): [Taggable!]! + + """Find native entities of this country""" + native: [Native!]! +} + +""" +Film entity. +The film belongs to one country and can be played by several actors. +""" +type Film implements Entity & Taggable { + id: ID! + fields(keys: [String!]): JSON! + tags: [Tag!]! + + """Title of film""" + title: String! @default + + """Genre of film.""" + genre: Genre! + + """ID of the country to which the film belongs""" + countryId: ID! @required + + """The country to which the film belongs""" + country: Country! + + """ + Find actors of film by firstName, lastName, birthday and gender. + Returns empty list if not found. + """ + actors( + """Part of first name of actor to search""" + firstName: String, + + """Part of last name of actor to search""" + lastName: String, + + """Find actors whose birthday is greater than or equal to birthdayFrom""" + birthdayFrom: Date, + + """Find actors whose birthday is less than or equal to birthdayTo""" + birthdayTo: Date, + + """Gender of actor to search""" + gender: Gender, + + """ + Limit of result list. + Put -1 to be unlimited. + """ + limit: Int! = 10, + + """Offset of result list.""" + offset: Int! = 0 + ): [Actor!]! @selection +} + +"""Enum of available film genres %""" +enum Genre { + """Drama %""" + DRAMA + + """Comedy""" + COMEDY + + """Thriller""" + THRILLER + + """Horror""" + HORROR +} + +"""Film input data %""" +input FilmInput { + """Title of film %""" + title: String! + + """Genre of film""" + genre: Genre! = DRAMA +} + +""" +Actor entity. +The actor belongs to one country and can play in several films. +""" +type Actor implements Entity & Taggable { + """Unique identifier of actor""" + id: ID! + fields(keys: [String!]): JSON! + tags: [Tag!]! + + """ + First name of actor. + I assume all actors have a first name - so field is not null. + """ + firstName: String! @default + + """ + Surname of the actor. + This field is nullable because the actor can use an alias and not have a last name. + """ + lastName: String @default + + """Actor's birthday""" + birthday: Date! @required + + """Actor's gender""" + gender: Gender! + + """ + ID of the country to which the actor belongs + (@primaryKey added to test complex primary keys) + """ + countryId: ID! @primaryKey + + """The country to which the actor belongs""" + country: Country! + + """ + Find films of actor by title and genre. + Returns empty list if not found. + """ + films( + """Part of title of film to search""" + title: String, + + """Genre of film to search""" + genre: Genre, + + """ + Limit of result list. + Put -1 to be unlimited. + """ + limit: Int! = 10, + + """Offset of result list.""" + offset: Int! = 0 + ): [Film!]! @selection +} + +"""The gender of the actor""" +enum Gender { + MALE + FEMALE +} + +"""Actor input data""" +input ActorInput { + """First name of actor.""" + firstName: String! + + """ + Surname of the actor. + This field is nullable because the actor can use an alias and not have a last name. + """ + lastName: String + + """Actor's birthday""" + birthday: Date! + + """Actor's gender""" + gender: Gender! +} + +""" +Type Tag is introduced for testing types with single value. +See class TagDto. +""" +type Tag { + """The tag value""" + value: String! +} + +""" +Input TagInput is introduced for testing inputs with single value. +See class TagInput. +""" +input TagInput { + """The tag value""" + value: String! +} + +"""Union of natives %""" +union Native = Actor | Film \ No newline at end of file diff --git a/kobby-gradle-tests/cinema-jackson-simple/src/test/kotlin/PluginTest.kt b/kobby-gradle-tests/cinema-jackson-simple/src/test/kotlin/PluginTest.kt new file mode 100644 index 00000000..8f80bf7a --- /dev/null +++ b/kobby-gradle-tests/cinema-jackson-simple/src/test/kotlin/PluginTest.kt @@ -0,0 +1,45 @@ +import io.kotest.core.spec.style.AnnotationSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.serialization.jackson.* +import kobby.kotlin.CinemaContext +import kobby.kotlin.adapter.ktor.CinemaSimpleKtorAdapter +import kobby.kotlin.cinemaContextOf +import kotlinx.coroutines.runBlocking + +class PluginTest : AnnotationSpec() { + + private fun schema(): CinemaContext { + val client = HttpClient(CIO) { + install(ContentNegotiation) { + jackson() + } + } + + val targetHost = System.getProperty("kobby-gradle-tests.server-url") + checkNotNull(targetHost) { + "webserver doesn't provide server url: check your gradle configuration" + } + + return cinemaContextOf( + CinemaSimpleKtorAdapter( + client, + "http://$targetHost/graphql" + ) + ) + } + + @Test + suspend fun `query request`() { + val result = schema().query { + films { + limit = 5 + title + } + } + + result.films shouldHaveSize 5 + } +} \ No newline at end of file diff --git a/kobby-gradle-tests/cinema-serialization-noparentheses/build.gradle.kts b/kobby-gradle-tests/cinema-serialization-noparentheses/build.gradle.kts new file mode 100644 index 00000000..65053e7a --- /dev/null +++ b/kobby-gradle-tests/cinema-serialization-noparentheses/build.gradle.kts @@ -0,0 +1,38 @@ +plugins { + `kobby-conventions` + alias(testLibs.plugins.kotlinx.serialization) +} + +kobby { + kotlin { + scalars = mapOf( + "Date" to typeOf("java.time", "LocalDate").serializer("serializers", "LocalDateSerializer"), + "JSON" to typeOf("kotlinx.serialization.json", "JsonObject") + ) + + entity { + projection { + enableNotationWithoutParentheses = true + } + } + + dto { + serialization { + enabled = true + } + } + + adapter { + extendedApi = true + ktor { + compositeEnabled = true + } + } + } +} + +dependencies { + implementation(testLibs.ktor.cio) + implementation(testLibs.ktor.content.negotiation) + implementation(testLibs.ktor.serialization.kotlinx.json) +} diff --git a/kobby-gradle-tests/cinema-serialization-noparentheses/src/main/kotlin/_CinemaContext.kt b/kobby-gradle-tests/cinema-serialization-noparentheses/src/main/kotlin/_CinemaContext.kt new file mode 100644 index 00000000..33251d07 --- /dev/null +++ b/kobby-gradle-tests/cinema-serialization-noparentheses/src/main/kotlin/_CinemaContext.kt @@ -0,0 +1,118 @@ +import kobby.kotlin.CinemaContext +import kobby.kotlin.CinemaReceiver +import kobby.kotlin.CinemaSubscriber +import kobby.kotlin.dto.ActorInput +import kobby.kotlin.dto.FilmInput +import kobby.kotlin.entity.* +import java.time.LocalDate + +/** + * Created on 13.03.2021 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ + +suspend fun CinemaContext.findCountry(id: Long, __projection: CountryProjection.() -> Unit = {}): Country? = + query { + country(id, __projection) + }.country + +suspend fun CinemaContext.fetchCountry(id: Long, __projection: CountryProjection.() -> Unit = {}): Country = + findCountry(id, __projection)!! + +//********************************************************************************************************************** + +suspend fun CinemaContext.findFilm(id: Long, __projection: FilmProjection.() -> Unit = {}): Film? = + query { + film(id, __projection) + }.film + +suspend fun CinemaContext.fetchFilm(id: Long, __projection: FilmProjection.() -> Unit = {}): Film = + findFilm(id, __projection)!! + +//********************************************************************************************************************** + +suspend fun CinemaContext.findActor(id: Long, __projection: ActorProjection.() -> Unit = {}): Actor? = + query { + actor(id, __projection) + }.actor + +suspend fun CinemaContext.fetchActor(id: Long, __projection: ActorProjection.() -> Unit = {}): Actor = + findActor(id, __projection)!! + +//********************************************************************************************************************** + +suspend fun CinemaContext.createCountry( + name: String, + __projection: CountryProjection.() -> Unit = {} +): Country = + mutation { + createCountry(name, __projection) + }.createCountry + +suspend fun CinemaContext.createFilm( + countryId: Long, + film: FilmInput, + __query: MutationCreateFilmQuery.() -> Unit = {} +): Film = + mutation { + createFilm(countryId, film, __query) + }.createFilm + +suspend fun CinemaContext.createActor( + countryId: Long, + actor: ActorInput, + __query: MutationCreateActorQuery.() -> Unit = {} +): Actor = + mutation { + createActor(countryId, actor, __query) + }.createActor + +suspend fun CinemaContext.updateBirthday( + actorId: Long, + birthday: LocalDate, + __projection: ActorProjection.() -> Unit = {} +): Actor? = + mutation { + updateBirthday(actorId, birthday, __projection) + }.updateBirthday + +//********************************************************************************************************************** + +fun CinemaContext.onCountryCreated( + __projection: CountryProjection.() -> Unit = {} +): CinemaSubscriber = CinemaSubscriber { + subscription { + countryCreated(__projection) + }.subscribe { + it(CinemaReceiver { + receive().countryCreated + }) + } +} + +fun CinemaContext.onFilmCreated( + countryId: Long?, + __projection: FilmProjection.() -> Unit = {} +): CinemaSubscriber = CinemaSubscriber { + subscription { + filmCreated(countryId, __projection) + }.subscribe { + it(CinemaReceiver { + receive().filmCreated + }) + } +} + +fun CinemaContext.onActorCreated( + countryId: Long?, + __projection: ActorProjection.() -> Unit = {} +): CinemaSubscriber = CinemaSubscriber { + subscription { + actorCreated(countryId, __projection) + }.subscribe { + it(CinemaReceiver { + receive().actorCreated + }) + } +} \ No newline at end of file diff --git a/kobby-gradle-tests/cinema-serialization-noparentheses/src/main/kotlin/entity/_Actor.kt b/kobby-gradle-tests/cinema-serialization-noparentheses/src/main/kotlin/entity/_Actor.kt new file mode 100644 index 00000000..aae89b65 --- /dev/null +++ b/kobby-gradle-tests/cinema-serialization-noparentheses/src/main/kotlin/entity/_Actor.kt @@ -0,0 +1,29 @@ +import kobby.kotlin.entity.Actor +import kobby.kotlin.entity.ActorProjection +import java.time.LocalDate + +/** + * Created on 21.05.2021 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ + +suspend fun Actor.refresh(id: Long, __projection: (ActorProjection.() -> Unit)? = null): Actor = + __context().fetchActor(id) { + __projection?.invoke(this) ?: __withCurrentProjection() + } + +suspend fun Actor.addFilm(filmId: Long): Boolean = __context().mutation { + associate(filmId, id) +}.associate + +suspend fun Actor.tag(tagValue: String): Boolean = __context().mutation { + tagActor(id, tagValue) +}.tagActor + +suspend fun Actor.updateBirthday( + birthday: LocalDate, + __projection: (ActorProjection.() -> Unit)? = null +): Actor = __context().updateBirthday(id, birthday) { + __projection?.invoke(this) ?: __withCurrentProjection() +} ?: error("Cannot find actor by id = $id") \ No newline at end of file diff --git a/kobby-gradle-tests/cinema-serialization-noparentheses/src/main/kotlin/entity/_Country.kt b/kobby-gradle-tests/cinema-serialization-noparentheses/src/main/kotlin/entity/_Country.kt new file mode 100644 index 00000000..d6bfd4d3 --- /dev/null +++ b/kobby-gradle-tests/cinema-serialization-noparentheses/src/main/kotlin/entity/_Country.kt @@ -0,0 +1,51 @@ +package entity + +import createActor +import createFilm +import kobby.kotlin.CinemaSubscriber +import kobby.kotlin.dto.ActorInput +import kobby.kotlin.dto.FilmInput +import kobby.kotlin.entity.* +import onActorCreated +import onFilmCreated + +/** + * Created on 13.03.2021 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ + +suspend fun Country.refresh(__projection: (CountryProjection.() -> Unit)? = null): Country = __context().query { + country(id) { + __projection?.invoke(this) ?: __withCurrentProjection() + } +}.country!! + +suspend fun Country.findFilm(id: Long, __projection: FilmProjection.() -> Unit = {}): Film? = refresh { + __minimize() // switch off all default fields to minimize GraphQL response + film(id, __projection) +}.film + +suspend fun Country.fetchFilm(id: Long, __projection: FilmProjection.() -> Unit = {}): Film = + findFilm(id, __projection)!! + +suspend fun Country.findFilms(__query: CountryFilmsQuery.() -> Unit = {}): List = refresh { + __minimize() // switch off all default fields to minimize GraphQL response + films(__query) +}.films + +//********************************************************************************************************************** + +suspend fun Country.createFilm(film: FilmInput, __query: MutationCreateFilmQuery.() -> Unit = {}): Film = + __context().createFilm(id, film, __query) + +suspend fun Country.createActor(actor: ActorInput, __query: MutationCreateActorQuery.() -> Unit = {}): Actor = + __context().createActor(id, actor, __query) + +//********************************************************************************************************************** + +fun Country.onFilmCreated(__projection: FilmProjection.() -> Unit = {}): CinemaSubscriber = + __context().onFilmCreated(id, __projection) + +fun Country.onActorCreated(__projection: ActorProjection.() -> Unit = {}): CinemaSubscriber = + __context().onActorCreated(id, __projection) \ No newline at end of file diff --git a/kobby-gradle-tests/cinema-serialization-noparentheses/src/main/kotlin/entity/_Film.kt b/kobby-gradle-tests/cinema-serialization-noparentheses/src/main/kotlin/entity/_Film.kt new file mode 100644 index 00000000..b5651ead --- /dev/null +++ b/kobby-gradle-tests/cinema-serialization-noparentheses/src/main/kotlin/entity/_Film.kt @@ -0,0 +1,24 @@ +package entity + +import fetchFilm +import kobby.kotlin.entity.Film +import kobby.kotlin.entity.FilmProjection + +/** + * Created on 21.05.2021 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ + +suspend fun Film.refresh(__projection: (FilmProjection.() -> Unit)? = null): Film = + __context().fetchFilm(id) { + __projection?.invoke(this) ?: __withCurrentProjection() + } + +suspend fun Film.addActor(actorId: Long): Boolean = __context().mutation { + associate(id, actorId) +}.associate + +suspend fun Film.tag(tagValue: String): Boolean = __context().mutation { + tagFilm(id, tagValue) +}.tagFilm \ No newline at end of file diff --git a/kobby-gradle-tests/cinema-serialization-noparentheses/src/main/kotlin/serializers/LocalDateSerializer.kt b/kobby-gradle-tests/cinema-serialization-noparentheses/src/main/kotlin/serializers/LocalDateSerializer.kt new file mode 100644 index 00000000..08d889ce --- /dev/null +++ b/kobby-gradle-tests/cinema-serialization-noparentheses/src/main/kotlin/serializers/LocalDateSerializer.kt @@ -0,0 +1,19 @@ +package serializers + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import java.time.LocalDate + +object LocalDateSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LocalDate", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: LocalDate) = + encoder.encodeString(value.toString()) + + override fun deserialize(decoder: Decoder): LocalDate = + LocalDate.parse(decoder.decodeString()) +} \ No newline at end of file diff --git a/kobby-gradle-tests/cinema-serialization-noparentheses/src/main/resources/cinema.graphqls b/kobby-gradle-tests/cinema-serialization-noparentheses/src/main/resources/cinema.graphqls new file mode 100644 index 00000000..c5344315 --- /dev/null +++ b/kobby-gradle-tests/cinema-serialization-noparentheses/src/main/resources/cinema.graphqls @@ -0,0 +1,486 @@ +directive @primaryKey on FIELD_DEFINITION +directive @required on FIELD_DEFINITION +directive @default on FIELD_DEFINITION +directive @selection on FIELD_DEFINITION + +scalar JSON +scalar Date + +schema { + query: Query + mutation: Mutation + subscription: Subscription +} + +type Query { + """ + Find country by id. + Returns null if not found. + """ + country(id: ID!): Country + + """ + Find countries by name. + Returns empty list if not found. + """ + countries( + """Part of name of country to search %""" + name: String, + + """ + Limit of result list. + Put -1 to be unlimited. + """ + limit: Int! = 10, + + """Offset of result list.""" + offset: Int! = 0 + ): [Country!]! + + """ + Find film by id. + Returns null if not found. + """ + film(id: ID!): Film + + """ + Find films by title and genre. + Returns empty list if not found. + """ + films( + """Part of title of film to search""" + title: String, + + """Genre of film to search""" + genre: Genre, + + """ + Limit of result list. + Put -1 to be unlimited. + """ + limit: Int! = 10, + + """Offset of result list.""" + offset: Int! = 0 + ): [Film!]! @selection + + """ + Find actor by id. + Returns null if not found. + """ + actor(id: ID!): Actor + + """ + Find actors by firstName, lastName, birthday and gender. + Returns empty list if not found. + """ + actors( + """Part of first name of actor to search""" + firstName: String, + + """Part of last name of actor to search""" + lastName: String, + + """Find actors whose birthday is greater than or equal to birthdayFrom""" + birthdayFrom: Date, + + """Find actors whose birthday is less than or equal to birthdayTo""" + birthdayTo: Date, + + """Gender of actor to search""" + gender: Gender, + + """ + Limit of result list. + Put -1 to be unlimited. + """ + limit: Int! = 10, + + """Offset of result list.""" + offset: Int! = 0 + ): [Actor!]! @selection + + """Find entities by tag""" + taggable(tag: String!): [Taggable!]! +} + +type Mutation { + """Create country""" + createCountry( + """Name of the country""" + name: String! + ): Country! + + """Create film""" + createFilm( + """ID of the country to which the film belongs""" + countryId: ID!, + + """Film input data""" + film: FilmInput!, + + """Add tag to film if tag is not null""" + tags: TagInput + ): Film! @selection + + """Create actor""" + createActor( + """ID of the country to which the actor belongs""" + countryId: ID!, + + """Actor input data""" + actor: ActorInput!, + + """Add tag to actor if tag is not null""" + tags: TagInput + ): Actor! @selection + + """Update birthday of actor""" + updateBirthday( + """ID of the actor to update""" + actorId: ID!, + + """New birthday""" + birthday: Date! + ): Actor + + """Associate film with actor""" + associate( + """ID of film %""" + filmId: ID!, + + """ID of actor""" + actorId: ID! + ): Boolean! + + """ + Add tag to film. + Returns false if the film already had such a tag + """ + tagFilm( + """ID of film""" + filmId: ID!, + + """Tag value""" + tagValue: String! + ): Boolean! + + """ + Add tag to actor. + Returns false if the actor already had such a tag + """ + tagActor( + """ID of actor""" + actorId: ID!, + + """Tag value""" + tagValue: String! + ): Boolean! + + """ + Remove all changes and return the internal state to its original state + """ + truncateMutations: Boolean! +} + +type Subscription { + """Listen new countries""" + countryCreated: Country! + + """Listen new films""" + filmCreated(countryId: ID): Film! + + """Listen new actors""" + actorCreated(countryId: ID): Actor! +} + +"""Base interface for all entities. %""" +interface Entity { + """Unique identifier of entity %""" + id: ID! @primaryKey + + """ + Fields of entity in key-value map. + Introduced for testing complex scalars. + """ + fields( + """ + List of field keys to load. % + If no keys specified all entity fields will be loaded. + """ + keys: [String!] + ): JSON! @selection +} + +""" +Entity with tags. +Introduced for testing complex inheritance. +""" +interface Taggable implements Entity { + id: ID! + fields(keys: [String!]): JSON! + + """Tags of entity""" + tags: [Tag!]! +} + +""" +Country entity. +The country can be home to several films and several actors. % +""" +type Country implements Entity { + id: ID! + fields(keys: [String!]): JSON! + + """The name of the country %""" + name: String! @default + + """ + Find film by id. + Returns null if not found. + """ + film(id: ID!): Film + + """ + Find films of country by title and genre. + Returns empty list if not found. + """ + films( + """Part of title of film to search %""" + title: String, + + """Genre of film to search %""" + genre: Genre, + + """ + Limit of result list. + Put -1 to be unlimited. + """ + limit: Int! = 10, + + """Offset of result list.""" + offset: Int! = 0 + ): [Film!]! @selection + + """ + Find actor by id. + Returns null if not found. + """ + actor(id: ID!): Actor + + """ + Find actors of country by firstName, lastName, birthday and gender. + Returns empty list if not found. + """ + actors( + """Part of first name of actor to search""" + firstName: String, + + """Part of last name of actor to search""" + lastName: String, + + """Find actors whose birthday is greater than or equal to birthdayFrom""" + birthdayFrom: Date, + + """Find actors whose birthday is less than or equal to birthdayTo""" + birthdayTo: Date, + + """Gender of actor to search""" + gender: Gender, + + """ + Limit of result list. + Put -1 to be unlimited. + """ + limit: Int! = 10, + + """Offset of result list.""" + offset: Int! = 0 + ): [Actor!]! @selection + + """Find entities of this country by tag""" + taggable(tag: String!): [Taggable!]! + + """Find native entities of this country""" + native: [Native!]! +} + +""" +Film entity. +The film belongs to one country and can be played by several actors. +""" +type Film implements Entity & Taggable { + id: ID! + fields(keys: [String!]): JSON! + tags: [Tag!]! + + """Title of film""" + title: String! @default + + """Genre of film.""" + genre: Genre! + + """ID of the country to which the film belongs""" + countryId: ID! @required + + """The country to which the film belongs""" + country: Country! + + """ + Find actors of film by firstName, lastName, birthday and gender. + Returns empty list if not found. + """ + actors( + """Part of first name of actor to search""" + firstName: String, + + """Part of last name of actor to search""" + lastName: String, + + """Find actors whose birthday is greater than or equal to birthdayFrom""" + birthdayFrom: Date, + + """Find actors whose birthday is less than or equal to birthdayTo""" + birthdayTo: Date, + + """Gender of actor to search""" + gender: Gender, + + """ + Limit of result list. + Put -1 to be unlimited. + """ + limit: Int! = 10, + + """Offset of result list.""" + offset: Int! = 0 + ): [Actor!]! @selection +} + +"""Enum of available film genres %""" +enum Genre { + """Drama %""" + DRAMA + + """Comedy""" + COMEDY + + """Thriller""" + THRILLER + + """Horror""" + HORROR +} + +"""Film input data %""" +input FilmInput { + """Title of film %""" + title: String! + + """Genre of film""" + genre: Genre! = DRAMA +} + +""" +Actor entity. +The actor belongs to one country and can play in several films. +""" +type Actor implements Entity & Taggable { + """Unique identifier of actor""" + id: ID! + fields(keys: [String!]): JSON! + tags: [Tag!]! + + """ + First name of actor. + I assume all actors have a first name - so field is not null. + """ + firstName: String! @default + + """ + Surname of the actor. + This field is nullable because the actor can use an alias and not have a last name. + """ + lastName: String @default + + """Actor's birthday""" + birthday: Date! @required + + """Actor's gender""" + gender: Gender! + + """ + ID of the country to which the actor belongs + (@primaryKey added to test complex primary keys) + """ + countryId: ID! @primaryKey + + """The country to which the actor belongs""" + country: Country! + + """ + Find films of actor by title and genre. + Returns empty list if not found. + """ + films( + """Part of title of film to search""" + title: String, + + """Genre of film to search""" + genre: Genre, + + """ + Limit of result list. + Put -1 to be unlimited. + """ + limit: Int! = 10, + + """Offset of result list.""" + offset: Int! = 0 + ): [Film!]! @selection +} + +"""The gender of the actor""" +enum Gender { + MALE + FEMALE +} + +"""Actor input data""" +input ActorInput { + """First name of actor.""" + firstName: String! + + """ + Surname of the actor. + This field is nullable because the actor can use an alias and not have a last name. + """ + lastName: String + + """Actor's birthday""" + birthday: Date! + + """Actor's gender""" + gender: Gender! +} + +""" +Type Tag is introduced for testing types with single value. +See class TagDto. +""" +type Tag { + """The tag value""" + value: String! +} + +""" +Input TagInput is introduced for testing inputs with single value. +See class TagInput. +""" +input TagInput { + """The tag value""" + value: String! +} + +"""Union of natives %""" +union Native = Actor | Film \ No newline at end of file diff --git a/kobby-gradle-tests/cinema-serialization-noparentheses/src/test/kotlin/CinemaServerTest.kt b/kobby-gradle-tests/cinema-serialization-noparentheses/src/test/kotlin/CinemaServerTest.kt new file mode 100644 index 00000000..9fea124a --- /dev/null +++ b/kobby-gradle-tests/cinema-serialization-noparentheses/src/test/kotlin/CinemaServerTest.kt @@ -0,0 +1,658 @@ +import ContextHolder.context +import entity.* +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.AnnotationSpec +import io.kotest.matchers.maps.shouldContainExactly +import io.kotest.matchers.shouldBe +import kobby.kotlin.dto.* +import kobby.kotlin.entity.Actor +import kobby.kotlin.entity.Film +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.time.LocalDate + +class CinemaServerTest : AnnotationSpec() { + + @BeforeAll + suspend fun setUp() { + context.mutation { truncateMutations } + } + + @Test + suspend fun createCountryWithFilmAndActorsByMeansOfGeneratedAPI() { + val country = context.mutation { + createCountry("USSR") + }.createCountry + + country.name shouldBe "USSR" + + val film = context.mutation { + createFilm(country.id, FilmInput("Hedgehog in the fog")) { + // tags is selection argument - see @selection directive + tags = TagInput { + value = "cool" + } + + genre + country() + tags { + value + } + } + }.createFilm + + film.title shouldBe "Hedgehog in the fog" + film.tags.also { + it.size shouldBe 1 + it[0].value shouldBe "cool" + } + film.genre shouldBe Genre.DRAMA + film.countryId shouldBe country.id + film.country.id shouldBe country.id + film.country.name shouldBe "USSR" + film.toString() shouldBe "Film(id=${film.id}, tags=[Tag(value=cool)], title=Hedgehog in the fog, genre=DRAMA, countryId=${film.countryId}, country=Country(id=${film.country.id}, name=USSR))" + + var actor = context.mutation { + createActor(country.id, ActorInput { + firstName = "Hedgehog" + birthday = LocalDate.of(1975, 3, 15) + gender = Gender.MALE + }) { + gender + country() + tags { + value + } + } + }.createActor + + actor.firstName shouldBe "Hedgehog" + actor.lastName shouldBe null + actor.birthday shouldBe LocalDate.of(1975, 3, 15) + actor.gender shouldBe Gender.MALE + actor.tags.isEmpty() shouldBe true + actor.countryId shouldBe country.id + actor.country.id shouldBe country.id + actor.country.name shouldBe "USSR" + actor.toString() shouldBe "Actor(id=${actor.id}, tags=[], firstName=Hedgehog, lastName=null, birthday=1975-03-15, gender=MALE, countryId=${actor.countryId}, country=Country(id=${actor.country.id}, name=USSR))" + + actor = context.mutation { + updateBirthday(actor.id, LocalDate.of(1976, 4, 16)) { + gender + country() + tags { + value + } + } + }.updateBirthday!! + + actor.firstName shouldBe "Hedgehog" + actor.lastName shouldBe null + actor.birthday shouldBe LocalDate.of(1976, 4, 16) + actor.gender shouldBe Gender.MALE + actor.tags.isEmpty() shouldBe true + actor.countryId shouldBe country.id + actor.country.id shouldBe country.id + actor.country.name shouldBe "USSR" + actor.toString() shouldBe "Actor(id=${actor.id}, tags=[], firstName=Hedgehog, lastName=null, birthday=1976-04-16, gender=MALE, countryId=${actor.countryId}, country=Country(id=${actor.country.id}, name=USSR))" + + context.mutation { + associate(film.id, actor.id) + }.associate shouldBe true + + context.mutation { + associate(film.id, actor.id) + }.associate shouldBe false + + context.mutation { + tagFilm(film.id, "cool") + }.tagFilm shouldBe false + + context.mutation { + tagActor(actor.id, "cool") + }.tagActor shouldBe true + + val ussr = context.query { + country(country.id) { + films { + limit = -1 + genre + country() + tags { + value + } + + // Actors of film + actors { + limit = -1 + gender + country() + tags { + value + } + } + } + } + }.country!! + + ussr.name shouldBe "USSR" + ussr.films.also { ussrFilms -> + ussrFilms.size shouldBe 1 + + ussrFilms[0].title shouldBe "Hedgehog in the fog" + ussrFilms[0].genre shouldBe Genre.DRAMA + ussrFilms[0].countryId shouldBe country.id + ussrFilms[0].country.id shouldBe country.id + ussrFilms[0].country.name shouldBe "USSR" + ussrFilms[0].tags.also { + it.size shouldBe 1 + it[0].value shouldBe "cool" + } + ussrFilms[0].actors.also { filmActors -> + filmActors.size shouldBe 1 + + filmActors[0].firstName shouldBe "Hedgehog" + filmActors[0].lastName shouldBe null + filmActors[0].birthday shouldBe LocalDate.of(1976, 4, 16) + filmActors[0].gender shouldBe Gender.MALE + filmActors[0].countryId shouldBe country.id + filmActors[0].country.id shouldBe country.id + filmActors[0].country.name shouldBe "USSR" + filmActors[0].tags.also { + it.size shouldBe 1 + it[0].value shouldBe "cool" + } + } + } + ussr.toString() shouldBe "Country(id=${country.id}, name=USSR, films=[Film(id=${film.id}, tags=[Tag(value=cool)], title=Hedgehog in the fog, genre=DRAMA, countryId=${country.id}, country=Country(id=${country.id}, name=USSR), actors=[Actor(id=${actor.id}, tags=[Tag(value=cool)], firstName=Hedgehog, lastName=null, birthday=1976-04-16, gender=MALE, countryId=${country.id}, country=Country(id=${country.id}, name=USSR))])])" + } + + @Test + suspend fun createCountryWithFilmAndActorsByMeansOfCustomizedAPI() { + val country = context.createCountry("USSR") + + country.name shouldBe "USSR" + + val film = country.createFilm(FilmInput("Hedgehog in the fog")) { + // tags is selection argument - see @selection directive + tags = TagInput { + value = "cool" + } + + genre + country() + tags { + value + } + } + + film.title shouldBe "Hedgehog in the fog" + film.tags.also { + it.size shouldBe 1 + it[0].value shouldBe "cool" + } + film.genre shouldBe Genre.DRAMA + film.countryId shouldBe country.id + film.country.id shouldBe country.id + film.country.name shouldBe "USSR" + film.toString() shouldBe "Film(id=${film.id}, tags=[Tag(value=cool)], title=Hedgehog in the fog, genre=DRAMA, countryId=${film.countryId}, country=Country(id=${film.country.id}, name=USSR))" + + var actor = country.createActor(ActorInput { + firstName = "Hedgehog" + birthday = LocalDate.of(1975, 3, 15) + gender = Gender.MALE + }) { + gender + country() + tags { + value + } + } + + actor.firstName shouldBe "Hedgehog" + actor.lastName shouldBe null + actor.birthday shouldBe LocalDate.of(1975, 3, 15) + actor.gender shouldBe Gender.MALE + actor.tags.isEmpty() shouldBe true + actor.countryId shouldBe country.id + actor.country.id shouldBe country.id + actor.country.name shouldBe "USSR" + actor.toString() shouldBe "Actor(id=${actor.id}, tags=[], firstName=Hedgehog, lastName=null, birthday=1975-03-15, gender=MALE, countryId=${actor.countryId}, country=Country(id=${actor.country.id}, name=USSR))" + + actor = actor.updateBirthday(LocalDate.of(1976, 4, 16)) + + actor.firstName shouldBe "Hedgehog" + actor.lastName shouldBe null + actor.birthday shouldBe LocalDate.of(1976, 4, 16) + actor.gender shouldBe Gender.MALE + actor.tags.isEmpty() shouldBe true + actor.countryId shouldBe country.id + actor.country.id shouldBe country.id + actor.country.name shouldBe "USSR" + actor.toString() shouldBe "Actor(id=${actor.id}, tags=[], firstName=Hedgehog, lastName=null, birthday=1976-04-16, gender=MALE, countryId=${actor.countryId}, country=Country(id=${actor.country.id}, name=USSR))" + + film.addActor(actor.id) shouldBe true + actor.addFilm(film.id) shouldBe false + + film.tag("cool") shouldBe false + actor.tag("cool") shouldBe true + + val ussr = country.refresh { + films { + limit = -1 + genre + country() + tags { + value + } + + // Actors of film + actors { + limit = -1 + gender + country() + tags { + value + } + } + } + } + + ussr.name shouldBe "USSR" + ussr.films.also { ussrFilms -> + ussrFilms.size shouldBe 1 + + ussrFilms[0].title shouldBe "Hedgehog in the fog" + ussrFilms[0].genre shouldBe Genre.DRAMA + ussrFilms[0].countryId shouldBe country.id + ussrFilms[0].country.id shouldBe country.id + ussrFilms[0].country.name shouldBe "USSR" + ussrFilms[0].tags.also { + it.size shouldBe 1 + it[0].value shouldBe "cool" + } + ussrFilms[0].actors.also { filmActors -> + filmActors.size shouldBe 1 + + filmActors[0].firstName shouldBe "Hedgehog" + filmActors[0].lastName shouldBe null + filmActors[0].birthday shouldBe LocalDate.of(1976, 4, 16) + filmActors[0].gender shouldBe Gender.MALE + filmActors[0].countryId shouldBe country.id + filmActors[0].country.id shouldBe country.id + filmActors[0].country.name shouldBe "USSR" + filmActors[0].tags.also { + it.size shouldBe 1 + it[0].value shouldBe "cool" + } + } + } + ussr.toString() shouldBe "Country(id=${country.id}, name=USSR, films=[Film(id=${film.id}, tags=[Tag(value=cool)], title=Hedgehog in the fog, genre=DRAMA, countryId=${country.id}, country=Country(id=${country.id}, name=USSR), actors=[Actor(id=${actor.id}, tags=[Tag(value=cool)], firstName=Hedgehog, lastName=null, birthday=1976-04-16, gender=MALE, countryId=${country.id}, country=Country(id=${country.id}, name=USSR))])])" + } + + @Test + suspend fun simpleQuery() { + val country = context.query { + country(1) + }.country!! + + country.id shouldBe 1 + country.name shouldBe "Argentina" + + shouldThrow { + country.fields + }.message shouldBe "Property [fields] is not available - add [fields] projection to switch on it" + + val films = context.query { + films { + genre = Genre.COMEDY + offset = 2 + limit = 2 + genre + fields { + keys = listOf("title", "genre") + } + } + }.films + + films.size shouldBe 2 + films[0].apply { + id shouldBe 5 + title shouldBe "House" + genre shouldBe Genre.COMEDY + countryId shouldBe 18 + shouldThrow { + this.country + }.message shouldBe "Property [country] is not available - add [country] projection to switch on it" + fields shouldContainExactly buildJsonObject { + put("title", "House") + put("genre", "COMEDY") + } + } + films[1].apply { + id shouldBe 6 + title shouldBe "Peter's Friends" + genre shouldBe Genre.COMEDY + countryId shouldBe 18 + fields shouldContainExactly buildJsonObject { + put("title", "Peter's Friends") + put("genre", "COMEDY") + } + } + + val actors = context.query { + actors { + gender = Gender.FEMALE + birthdayFrom = LocalDate.of(1967, 1, 1) + + gender + } + }.actors + actors.size shouldBe 2 + actors[0].apply { + id shouldBe 1 + firstName shouldBe "Audrey" + lastName shouldBe "Tautou" + gender shouldBe Gender.FEMALE + birthday shouldBe LocalDate.of(1976, 8, 9) + } + actors[1].apply { + id shouldBe 11 + firstName shouldBe "Julia" + lastName shouldBe "Roberts" + gender shouldBe Gender.FEMALE + birthday shouldBe LocalDate.of(1967, 10, 28) + } + } + + @Test + suspend fun complexQuery() { + val usa = context.query { + country(19) { + films { + title = "d" + genre + actors { + gender + country() + } + } + actors { + limit = 2 + gender + films { + genre = Genre.THRILLER + genre + country() + } + } + } + }.country!! + usa.id shouldBe 19 + usa.name shouldBe "USA" + + val usaFilms = usa.films + usaFilms.size shouldBe 1 + usaFilms[0].also { film -> + film.id shouldBe 12 + film.title shouldBe "From Dusk Till Dawn" + film.genre shouldBe Genre.THRILLER + film.countryId shouldBe 19 + + film.actors.size shouldBe 2 + film.actors[0].apply { + id shouldBe 12 + firstName shouldBe "George" + lastName shouldBe "Clooney" + birthday shouldBe LocalDate.of(1967, 10, 28) + gender shouldBe Gender.MALE + countryId shouldBe 19 + country.id shouldBe 19 + country.name shouldBe "USA" + } + film.actors[1].apply { + id shouldBe 16 + firstName shouldBe "Salma" + lastName shouldBe "Hayek" + birthday shouldBe LocalDate.of(1966, 9, 2) + gender shouldBe Gender.FEMALE + countryId shouldBe 19 + country.id shouldBe 19 + country.name shouldBe "USA" + } + } + + val usaActors = usa.actors + usaActors.size shouldBe 2 + usaActors[0].also { actor -> + actor.id shouldBe 10 + actor.firstName shouldBe "Keanu" + actor.lastName shouldBe "Reeves" + actor.birthday shouldBe LocalDate.of(1964, 9, 2) + actor.gender shouldBe Gender.MALE + actor.countryId shouldBe 19 + + actor.films.size shouldBe 1 + actor.films[0].apply { + id shouldBe 7 + title shouldBe "Street Kings" + genre shouldBe Genre.THRILLER + countryId shouldBe 18 + country.id shouldBe 18 + country.name shouldBe "United Kingdom" + } + } + usaActors[1].also { actor -> + actor.id shouldBe 11 + actor.firstName shouldBe "Julia" + actor.lastName shouldBe "Roberts" + actor.birthday shouldBe LocalDate.of(1967, 10, 28) + actor.gender shouldBe Gender.FEMALE + actor.countryId shouldBe 19 + + actor.films.size shouldBe 1 + actor.films[0].apply { + id shouldBe 9 + title shouldBe "Ocean's Eleven" + genre shouldBe Genre.THRILLER + countryId shouldBe 19 + country.id shouldBe 19 + country.name shouldBe "USA" + } + } + } + + @Test + suspend fun interfaceQuery() { + val list = context.query { + taggable("julia") { + tags { + value + } + __onFilm { + genre + } + __onActor { + gender + } + } + }.taggable + + list.size shouldBe 4 + list[0].also { + it.id shouldBe 9 + it.tags.size shouldBe 3 + it.tags[0].value shouldBe "best" + it.tags[1].value shouldBe "julia" + it.tags[2].value shouldBe "clooney" + (it as Film).apply { + title shouldBe "Ocean's Eleven" + genre shouldBe Genre.THRILLER + countryId shouldBe 19 + } + } + list[1].also { + it.id shouldBe 10 + it.tags.size shouldBe 1 + it.tags[0].value shouldBe "julia" + (it as Film).apply { + title shouldBe "Stepmom" + genre shouldBe Genre.DRAMA + countryId shouldBe 19 + } + } + list[2].also { + it.id shouldBe 11 + it.tags.size shouldBe 1 + it.tags[0].value shouldBe "julia" + (it as Film).apply { + title shouldBe "Pretty Woman" + genre shouldBe Genre.COMEDY + countryId shouldBe 19 + } + } + list[3].also { + it.id shouldBe 11 + it.tags.size shouldBe 2 + it.tags[0].value shouldBe "best" + it.tags[1].value shouldBe "julia" + (it as Actor).apply { + firstName shouldBe "Julia" + lastName shouldBe "Roberts" + birthday shouldBe LocalDate.of(1967, 10, 28) + gender shouldBe Gender.FEMALE + countryId shouldBe 19 + } + } + } + + @Test + suspend fun unionQuery() { + val list = context.query { + country(18) { + __minimize() + native { + __onFilm { + genre + } + __onActor { + gender + } + } + } + }.country!!.native + + list.size shouldBe 6 + (list[0] as Film).apply { + id shouldBe 5 + title shouldBe "House" + genre shouldBe Genre.COMEDY + countryId shouldBe 18 + } + (list[1] as Film).apply { + id shouldBe 6 + title shouldBe "Peter's Friends" + genre shouldBe Genre.COMEDY + countryId shouldBe 18 + } + (list[2] as Film).apply { + id shouldBe 7 + title shouldBe "Street Kings" + genre shouldBe Genre.THRILLER + countryId shouldBe 18 + } + (list[3] as Film).apply { + id shouldBe 8 + title shouldBe "Mr. Pip" + genre shouldBe Genre.DRAMA + countryId shouldBe 18 + } + (list[4] as Actor).apply { + id shouldBe 8 + firstName shouldBe "Hugh" + lastName shouldBe "Laurie" + birthday shouldBe LocalDate.of(1959, 6, 11) + gender shouldBe Gender.MALE + countryId shouldBe 18 + } + (list[5] as Actor).apply { + id shouldBe 9 + firstName shouldBe "Stephen" + lastName shouldBe "Fry" + birthday shouldBe LocalDate.of(1957, 8, 24) + gender shouldBe Gender.MALE + countryId shouldBe 18 + } + } + + @Test + suspend fun unqualifiedInterfaceQuery() { + val list = context.query { + taggable("julia") { + tags { + value + } + } + }.taggable + + list.size shouldBe 4 + list[0].also { + it.id shouldBe 9 + it.tags.size shouldBe 3 + it.tags[0].value shouldBe "best" + it.tags[1].value shouldBe "julia" + it.tags[2].value shouldBe "clooney" + (it as Film).apply { + title shouldBe "Ocean's Eleven" + countryId shouldBe 19 + shouldThrow { + genre + }.message shouldBe "Property [genre] is not available - add [genre] projection to switch on it" + } + } + list[3].also { + it.id shouldBe 11 + it.tags.size shouldBe 2 + it.tags[0].value shouldBe "best" + it.tags[1].value shouldBe "julia" + (it as Actor).apply { + firstName shouldBe "Julia" + lastName shouldBe "Roberts" + birthday shouldBe LocalDate.of(1967, 10, 28) + countryId shouldBe 19 + shouldThrow { + gender + }.message shouldBe "Property [gender] is not available - add [gender] projection to switch on it" + } + } + } + + @Test + suspend fun unqualifiedUnionQuery() { + val list = context.query { + country(18) { + native() + } + }.country!!.native + + list.size shouldBe 6 + (list[0] as Film).apply { + id shouldBe 5 + title shouldBe "House" + countryId shouldBe 18 + shouldThrow { + genre + }.message shouldBe "Property [genre] is not available - add [genre] projection to switch on it" + } + (list[5] as Actor).apply { + id shouldBe 9 + firstName shouldBe "Stephen" + lastName shouldBe "Fry" + birthday shouldBe LocalDate.of(1957, 8, 24) + countryId shouldBe 18 + shouldThrow { + gender + }.message shouldBe "Property [gender] is not available - add [gender] projection to switch on it" + } + } +} \ No newline at end of file diff --git a/kobby-gradle-tests/cinema-serialization-noparentheses/src/test/kotlin/CinemaSubscriptionsTest.kt b/kobby-gradle-tests/cinema-serialization-noparentheses/src/test/kotlin/CinemaSubscriptionsTest.kt new file mode 100644 index 00000000..306ab22c --- /dev/null +++ b/kobby-gradle-tests/cinema-serialization-noparentheses/src/test/kotlin/CinemaSubscriptionsTest.kt @@ -0,0 +1,207 @@ +import ContextHolder.context +import entity.createActor +import entity.createFilm +import entity.onActorCreated +import entity.onFilmCreated +import io.kotest.core.spec.style.AnnotationSpec +import io.kotest.matchers.shouldBe +import kobby.kotlin.dto.ActorInput +import kobby.kotlin.dto.FilmInput +import kobby.kotlin.dto.Gender.FEMALE +import kobby.kotlin.dto.Gender.MALE +import kobby.kotlin.dto.Genre.* +import java.time.LocalDate + +class CinemaSubscriptionsTest : AnnotationSpec() { + + @BeforeAll + suspend fun setUp() { + context.mutation { truncateMutations } + } + + @Test + suspend fun subscriptionsByMeansOfGeneratedAPI() { + // Created countries subscription + context.subscription { + countryCreated() + }.subscribe { + // Create countries + context.mutation { + createCountry("First") + } + context.mutation { + createCountry("Second") + } + context.mutation { + createCountry("Third") + } + + // Listen created countries + receive().countryCreated.name shouldBe "First" + receive().countryCreated.name shouldBe "Second" + receive().countryCreated.name shouldBe "Third" + } + + // Created films subscription + context.subscription { + filmCreated(2) { + genre + country() + } + }.subscribe { + // Create films + context.mutation { + createFilm(2, FilmInput("First", COMEDY)) + } + context.mutation { + createFilm(6, FilmInput("Second", THRILLER)) + } + context.mutation { + createFilm(2, FilmInput("Third", HORROR)) + } + + // Listen created films + receive().filmCreated.also { first -> + first.title shouldBe "First" + first.genre shouldBe COMEDY + first.country.name shouldBe "Australia" + } + receive().filmCreated.also { third -> + third.title shouldBe "Third" + third.genre shouldBe HORROR + third.country.name shouldBe "Australia" + } + } + + // Created actors subscription + context.subscription { + actorCreated(2) { + gender + country() + } + }.subscribe { + // Create actors + val now = LocalDate.now() + context.mutation { + createActor(2, ActorInput { + firstName = "First" + lastName = "Actress" + birthday = now + gender = FEMALE + }) + } + context.mutation { + createActor(6, ActorInput { + firstName = "Second" + lastName = "Actress" + birthday = now + gender = FEMALE + }) + } + context.mutation { + createActor(2, ActorInput { + firstName = "Third" + lastName = "Actor" + birthday = now + gender = MALE + }) + } + + // Listen created actors + receive().actorCreated.also { first -> + first.firstName shouldBe "First" + first.lastName shouldBe "Actress" + first.birthday shouldBe now + first.gender shouldBe FEMALE + first.country.name shouldBe "Australia" + } + receive().actorCreated.also { third -> + third.firstName shouldBe "Third" + third.lastName shouldBe "Actor" + third.birthday shouldBe now + third.gender shouldBe MALE + third.country.name shouldBe "Australia" + } + } + } + + @Test + suspend fun subscriptionsByMeansOfCustomizedAPI() { + // Created countries subscription + context.onCountryCreated().subscribe { + // Create countries + context.createCountry("First") + context.createCountry("Second") + context.createCountry("Third") + + // Listen created countries + receive().name shouldBe "First" + receive().name shouldBe "Second" + receive().name shouldBe "Third" + } + + // Prepare countries + val australia = context.fetchCountry(2) + val canada = context.fetchCountry(6) + + // Created films subscription + australia.onFilmCreated { genre; country() }.subscribe { + // Create films + australia.createFilm(FilmInput("First", COMEDY)) + canada.createFilm(FilmInput("Second", THRILLER)) + australia.createFilm(FilmInput("Third", HORROR)) + + // Listen created films + receive().also { first -> + first.title shouldBe "First" + first.genre shouldBe COMEDY + first.country.name shouldBe "Australia" + } + receive().also { third -> + third.title shouldBe "Third" + third.genre shouldBe HORROR + third.country.name shouldBe "Australia" + } + } + + // Created actors subscription + australia.onActorCreated { gender; country() }.subscribe { + // Create actors + val now = LocalDate.now() + australia.createActor(ActorInput { + firstName = "First" + lastName = "Actress" + birthday = now + gender = FEMALE + }) + canada.createActor(ActorInput { + firstName = "Second" + lastName = "Actress" + birthday = now + gender = FEMALE + }) + australia.createActor(ActorInput { + firstName = "Third" + lastName = "Actor" + birthday = now + gender = MALE + }) + + // Listen created actors + receive().also { first -> + first.firstName shouldBe "First" + first.lastName shouldBe "Actress" + first.birthday shouldBe now + first.gender shouldBe FEMALE + first.country.name shouldBe "Australia" + } + receive().also { third -> + third.firstName shouldBe "Third" + third.lastName shouldBe "Actor" + third.birthday shouldBe now + third.gender shouldBe MALE + third.country.name shouldBe "Australia" + } + } + } +} \ No newline at end of file diff --git a/kobby-gradle-tests/cinema-serialization-noparentheses/src/test/kotlin/ContextHolder.kt b/kobby-gradle-tests/cinema-serialization-noparentheses/src/test/kotlin/ContextHolder.kt new file mode 100644 index 00000000..64ddbb3b --- /dev/null +++ b/kobby-gradle-tests/cinema-serialization-noparentheses/src/test/kotlin/ContextHolder.kt @@ -0,0 +1,42 @@ +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.plugins.websocket.* +import io.ktor.client.request.* +import io.ktor.serialization.kotlinx.json.* +import kobby.kotlin.CinemaContext +import kobby.kotlin.adapter.ktor.CinemaCompositeKtorAdapter +import kobby.kotlin.cinemaContextOf +import kobby.kotlin.cinemaJson + +object ContextHolder { + + val context: CinemaContext by lazy { schema() } + + private fun schema(): CinemaContext { + val client = HttpClient(CIO) { + expectSuccess = true + install(WebSockets) + install(DefaultRequest) { + header("Sec-WebSocket-Protocol", "graphql-transport-ws") + } + install(ContentNegotiation) { + json(cinemaJson) + } + } + + val targetHost = System.getProperty("kobby-gradle-tests.server-url") ?: "localhost:18080" + checkNotNull(targetHost) { + "webserver doesn't provide server url: check your gradle configuration" + } + + return cinemaContextOf( + CinemaCompositeKtorAdapter( + client, + "http://$targetHost/graphql", + "ws://$targetHost/graphql-ws" + ) + ) + } +} diff --git a/kobby-gradle-tests/libs.versions.toml b/kobby-gradle-tests/libs.versions.toml new file mode 100644 index 00000000..ecc21104 --- /dev/null +++ b/kobby-gradle-tests/libs.versions.toml @@ -0,0 +1,34 @@ +[versions] +extended-scalars = "24.0" +graphql-kotlin = "8.8.1" +jackson = "2.13.4" +kotlin-gradle-plugin = "2.3.0" +ktor = "2.3.13" +kotest = "5.8.0" +kotlinx-serialization = "2.3.0" +slf4j = "2.0.17" + +[libraries] +extended-scalars = { module = "com.graphql-java:graphql-java-extended-scalars", version.ref = "extended-scalars"} +graphql-kotlin = { module = "com.expediagroup:graphql-kotlin-ktor-server", version.ref = "graphql-kotlin" } +kotest-runner-junit5 = { module = "io.kotest:kotest-runner-junit5", version.ref = "kotest" } +kotest-assertions-core = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest" } +kotest-property = { module = "io.kotest:kotest-property", version.ref = "kotest" } +kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin-gradle-plugin"} +ktor-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor"} +ktor-server-netty = { module = "io.ktor:ktor-server-netty", version.ref = "ktor" } +ktor-server-websockets = { module = "io.ktor:ktor-server-websockets", version.ref = "ktor" } +ktor-server-cors = { module = "io.ktor:ktor-server-cors", version.ref = "ktor" } +ktor-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor"} +ktor-serialization-jackson = { module = "io.ktor:ktor-serialization-jackson", version.ref = "ktor"} +ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor"} +jackson-annotations = { module = "com.fasterxml.jackson.core:jackson-annotations", version.ref = "jackson" } +slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } +slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j" } + +[bundles] +kotest = [ "kotest-runner-junit5", "kotest-assertions-core", "kotest-property" ] +slf4j = [ "slf4j-api", "slf4j-simple" ] + +[plugins] +kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinx-serialization" } \ No newline at end of file diff --git a/kobby-gradle-tests/settings.gradle.kts b/kobby-gradle-tests/settings.gradle.kts new file mode 100644 index 00000000..8cf591df --- /dev/null +++ b/kobby-gradle-tests/settings.gradle.kts @@ -0,0 +1,14 @@ +rootProject.name = "kobby-gradle-tests" + +dependencyResolutionManagement { + versionCatalogs { + create("testLibs") { + from(files("libs.versions.toml")) + } + } +} + +include(":cinema-jackson-simple") +include(":cinema-jackson-composite") +include(":cinema-serialization-noparentheses") + diff --git a/settings.gradle.kts b/settings.gradle.kts index e135c753..204312f9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -25,3 +25,5 @@ include(":kobby-generator-kotlin") include(":kobby-gradle-plugin") include(":kobby-maven-plugin") +includeBuild("kobby-gradle-tests") +