diff --git a/cinema-api/build.gradle.kts b/cinema-api/build.gradle.kts index 9483854..5cda0de 100644 --- a/cinema-api/build.gradle.kts +++ b/cinema-api/build.gradle.kts @@ -15,4 +15,9 @@ dependencies { // Add this dependency to enable default Ktor adapters generation compileOnly("io.ktor:ktor-client-cio:2.3.13") + + // todo + compileOnly("com.graphql-java:graphql-java:24.3") + compileOnly("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1") + compileOnly("org.jetbrains.kotlinx:kotlinx-coroutines-reactive:1.8.1") } \ No newline at end of file diff --git a/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/cinemaSchemaProgramBuilder.kt b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/cinemaSchemaProgramBuilder.kt new file mode 100644 index 0000000..115c598 --- /dev/null +++ b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/cinemaSchemaProgramBuilder.kt @@ -0,0 +1,62 @@ +@file:Suppress("UNCHECKED_CAST") + +package io.github.ermadmi78.kobby.cinema.api.kobby.server + +import graphql.Scalars +import graphql.schema.DataFetchingEnvironment +import graphql.schema.GraphQLCodeRegistry +import graphql.schema.GraphQLScalarType +import graphql.schema.GraphQLSchema +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.resolver.FilmResolutionModel +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.resolver.MutationResolutionModel +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.resolver.QueryResolutionModel +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.resolver.SubscriptionResolutionModel +import io.github.ermadmi78.kobby.cinema.api.kobby.server.runtime.DEFAULT_CONTEXT_PROVIDER +import io.github.ermadmi78.kobby.cinema.api.kobby.server.runtime.DEFAULT_RESOLUTION_ASPECT +import io.github.ermadmi78.kobby.cinema.api.kobby.server.runtime.ResolutionAspect +import io.github.ermadmi78.kobby.cinema.api.kobby.server.specification.DEDAULT_ID_SCALAR +import io.github.ermadmi78.kobby.cinema.api.kobby.server.specification.program.code.* +import io.github.ermadmi78.kobby.cinema.api.kobby.server.specification.program.type.* +import kotlin.coroutines.CoroutineContext + +/** + * Created on 07.02.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ + +fun buildCinemaSchemaUsingProgram( + queryResolver: QueryResolutionModel, + mutationResolver: MutationResolutionModel, + subscriptionResolver: SubscriptionResolutionModel, + filmResolver: FilmResolutionModel, + scalarID: GraphQLScalarType = DEDAULT_ID_SCALAR, + scalarString: GraphQLScalarType = Scalars.GraphQLString, + aspect: ResolutionAspect = DEFAULT_RESOLUTION_ASPECT, + coroutineContextProvider: (DataFetchingEnvironment) -> CoroutineContext = DEFAULT_CONTEXT_PROVIDER +): GraphQLSchema { + require(scalarID.name == "ID") { + "For scalarID expected scalar with name 'ID', but found '${scalarID.name}'" + } + require(scalarString.name == "String") { + "For scalarString expected scalar with name 'String', but found '${scalarString.name}'" + } + + val codeRegistry: GraphQLCodeRegistry.Builder = GraphQLCodeRegistry.newCodeRegistry() + QueryCode.register(codeRegistry, queryResolver, aspect, coroutineContextProvider) + MutationCode.register(codeRegistry, mutationResolver, aspect, coroutineContextProvider) + SubscriptionCode.register(codeRegistry, subscriptionResolver, aspect, coroutineContextProvider) + FilmCode.register(codeRegistry, filmResolver, aspect, coroutineContextProvider) + ActorCode.register(codeRegistry) + + return GraphQLSchema.newSchema() + .additionalType(scalarID) + .additionalType(scalarString) + .query(QueryType.build()) + .mutation(MutationType.build()) + .subscription(SubscriptionType.build()) + .additionalType(FilmType.build()) + .additionalType(ActorType.build()) + .codeRegistry(codeRegistry.build()) + .build() +} \ No newline at end of file diff --git a/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/cinemaSchemaSDLBuilder.kt b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/cinemaSchemaSDLBuilder.kt new file mode 100644 index 0000000..aa98f32 --- /dev/null +++ b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/cinemaSchemaSDLBuilder.kt @@ -0,0 +1,91 @@ +package io.github.ermadmi78.kobby.cinema.api.kobby.server + +import graphql.Scalars +import graphql.schema.DataFetchingEnvironment +import graphql.schema.GraphQLScalarType +import graphql.schema.GraphQLSchema +import graphql.schema.idl.RuntimeWiring +import graphql.schema.idl.SchemaGenerator +import graphql.schema.idl.SchemaParser +import graphql.schema.idl.TypeDefinitionRegistry +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.resolver.FilmResolutionModel +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.resolver.MutationResolutionModel +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.resolver.QueryResolutionModel +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.resolver.SubscriptionResolutionModel +import io.github.ermadmi78.kobby.cinema.api.kobby.server.runtime.DEFAULT_CONTEXT_PROVIDER +import io.github.ermadmi78.kobby.cinema.api.kobby.server.runtime.DEFAULT_RESOLUTION_ASPECT +import io.github.ermadmi78.kobby.cinema.api.kobby.server.runtime.ResolutionAspect +import io.github.ermadmi78.kobby.cinema.api.kobby.server.specification.DEDAULT_ID_SCALAR +import io.github.ermadmi78.kobby.cinema.api.kobby.server.specification.sdl.* +import java.io.Reader +import kotlin.coroutines.CoroutineContext + +/** + * Created on 15.02.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ + +fun buildCinemaSchemaUsingSDL( + schemas: Sequence, + queryResolver: QueryResolutionModel, + mutationResolver: MutationResolutionModel, + subscriptionResolver: SubscriptionResolutionModel, + filmResolver: FilmResolutionModel, + scalarID: GraphQLScalarType = DEDAULT_ID_SCALAR, + scalarString: GraphQLScalarType = Scalars.GraphQLString, + aspect: ResolutionAspect = DEFAULT_RESOLUTION_ASPECT, + coroutineContextProvider: (DataFetchingEnvironment) -> CoroutineContext = DEFAULT_CONTEXT_PROVIDER +): GraphQLSchema { + val schemaParser = SchemaParser() + val registry = TypeDefinitionRegistry() + for (schema in schemas) { + registry.merge(schemaParser.parse(schema)) + } + + val wiring = RuntimeWiring.newRuntimeWiring().also { + wireCinemaSchemaRuntime( + it, + queryResolver, + mutationResolver, + subscriptionResolver, + filmResolver, + scalarID, + scalarString, + aspect, + coroutineContextProvider + ) + }.build() + + return SchemaGenerator().makeExecutableSchema(registry, wiring) +} + +fun wireCinemaSchemaRuntime( + runtimeWiring: RuntimeWiring.Builder, + queryResolver: QueryResolutionModel, + mutationResolver: MutationResolutionModel, + subscriptionResolver: SubscriptionResolutionModel, + filmResolver: FilmResolutionModel, + scalarID: GraphQLScalarType = DEDAULT_ID_SCALAR, + scalarString: GraphQLScalarType = Scalars.GraphQLString, + aspect: ResolutionAspect = DEFAULT_RESOLUTION_ASPECT, + coroutineContextProvider: (DataFetchingEnvironment) -> CoroutineContext = DEFAULT_CONTEXT_PROVIDER +) { + require(scalarID.name == "ID") { + "For scalarID expected scalar with name 'ID', but found '${scalarID.name}'" + } + require(scalarString.name == "String") { + "For scalarString expected scalar with name 'String', but found '${scalarString.name}'" + } + + runtimeWiring + .strictMode(false) + .scalar(scalarID) + .scalar(scalarString) + + QueryWiring.register(runtimeWiring, queryResolver, aspect, coroutineContextProvider) + MutationWiring.register(runtimeWiring, mutationResolver, aspect, coroutineContextProvider) + SubscriptionWiring.register(runtimeWiring, subscriptionResolver, aspect, coroutineContextProvider) + FilmWiring.register(runtimeWiring, filmResolver, aspect, coroutineContextProvider) + ActorWiring.register(runtimeWiring) +} \ No newline at end of file diff --git a/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/model/CinemaData.kt b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/model/CinemaData.kt new file mode 100644 index 0000000..fee6cc2 --- /dev/null +++ b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/model/CinemaData.kt @@ -0,0 +1,10 @@ +package io.github.ermadmi78.kobby.cinema.api.kobby.server.model + +/** + * Created on 16.02.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +interface CinemaData { + operator fun get(property: String): Any? +} \ No newline at end of file diff --git a/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/model/data/ActorData.kt b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/model/data/ActorData.kt new file mode 100644 index 0000000..61e9f87 --- /dev/null +++ b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/model/data/ActorData.kt @@ -0,0 +1,18 @@ +package io.github.ermadmi78.kobby.cinema.api.kobby.server.model.data + +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.CinemaData + +/** + * Created on 07.02.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +data class ActorData( + val id: Long? = null, + val firstName: String? = null, + val lastName: String? = null, + val __localContext: Map = emptyMap() +) : CinemaData { + override operator fun get(property: String): Any? = + __localContext[property] +} diff --git a/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/model/data/FilmData.kt b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/model/data/FilmData.kt new file mode 100644 index 0000000..a4d73ea --- /dev/null +++ b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/model/data/FilmData.kt @@ -0,0 +1,17 @@ +package io.github.ermadmi78.kobby.cinema.api.kobby.server.model.data + +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.CinemaData + +/** + * Created on 07.02.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +data class FilmData( + val id: Long? = null, + val title: String? = null, + val __localContext: Map = emptyMap() +) : CinemaData { + override operator fun get(property: String): Any? = + __localContext[property] +} \ No newline at end of file diff --git a/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/model/resolver/FilmResolutionModel.kt b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/model/resolver/FilmResolutionModel.kt new file mode 100644 index 0000000..37d7eb1 --- /dev/null +++ b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/model/resolver/FilmResolutionModel.kt @@ -0,0 +1,13 @@ +package io.github.ermadmi78.kobby.cinema.api.kobby.server.model.resolver + +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.data.ActorData +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.data.FilmData + +/** + * Created on 07.02.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +interface FilmResolutionModel { + suspend fun actors(source: FilmData): List +} \ No newline at end of file diff --git a/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/model/resolver/MutationResolutionModel.kt b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/model/resolver/MutationResolutionModel.kt new file mode 100644 index 0000000..8d39ce4 --- /dev/null +++ b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/model/resolver/MutationResolutionModel.kt @@ -0,0 +1,12 @@ +package io.github.ermadmi78.kobby.cinema.api.kobby.server.model.resolver + +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.data.FilmData + +/** + * Created on 07.02.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +interface MutationResolutionModel { + suspend fun createFilm(title: String): FilmData +} \ No newline at end of file diff --git a/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/model/resolver/QueryResolutionModel.kt b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/model/resolver/QueryResolutionModel.kt new file mode 100644 index 0000000..0374c34 --- /dev/null +++ b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/model/resolver/QueryResolutionModel.kt @@ -0,0 +1,14 @@ +package io.github.ermadmi78.kobby.cinema.api.kobby.server.model.resolver + +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.data.FilmData + +/** + * Created on 07.02.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +interface QueryResolutionModel { + suspend fun film(id: Long): FilmData? + + suspend fun films(): List +} \ No newline at end of file diff --git a/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/model/resolver/SubscriptionResolutionModel.kt b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/model/resolver/SubscriptionResolutionModel.kt new file mode 100644 index 0000000..beacd37 --- /dev/null +++ b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/model/resolver/SubscriptionResolutionModel.kt @@ -0,0 +1,13 @@ +package io.github.ermadmi78.kobby.cinema.api.kobby.server.model.resolver + +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.data.FilmData +import kotlinx.coroutines.flow.Flow + +/** + * Created on 07.02.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +interface SubscriptionResolutionModel { + suspend fun filmCreated(): Flow +} \ No newline at end of file diff --git a/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/runtime/ResolutionAspect.kt b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/runtime/ResolutionAspect.kt new file mode 100644 index 0000000..db482f5 --- /dev/null +++ b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/runtime/ResolutionAspect.kt @@ -0,0 +1,12 @@ +package io.github.ermadmi78.kobby.cinema.api.kobby.server.runtime + +import graphql.schema.DataFetchingEnvironment + +/** + * Created on 21.02.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +interface ResolutionAspect { + suspend fun around(environment: DataFetchingEnvironment, resolution: suspend () -> T): T +} \ No newline at end of file diff --git a/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/runtime/ResolutionContext.kt b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/runtime/ResolutionContext.kt new file mode 100644 index 0000000..cf581ec --- /dev/null +++ b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/runtime/ResolutionContext.kt @@ -0,0 +1,16 @@ +package io.github.ermadmi78.kobby.cinema.api.kobby.server.runtime + +import graphql.schema.DataFetchingEnvironment +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext + +/** + * Created on 16.02.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +class ResolutionContext( + val environment: DataFetchingEnvironment +) : AbstractCoroutineContextElement(ResolutionContext) { + companion object Key : CoroutineContext.Key +} \ No newline at end of file diff --git a/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/runtime/fetcher/FilmActorsFetcher.kt b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/runtime/fetcher/FilmActorsFetcher.kt new file mode 100644 index 0000000..5f696c4 --- /dev/null +++ b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/runtime/fetcher/FilmActorsFetcher.kt @@ -0,0 +1,32 @@ +package io.github.ermadmi78.kobby.cinema.api.kobby.server.runtime.fetcher + +import graphql.schema.DataFetcher +import graphql.schema.DataFetchingEnvironment +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.data.ActorData +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.resolver.FilmResolutionModel +import io.github.ermadmi78.kobby.cinema.api.kobby.server.runtime.ResolutionAspect +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.future.asCompletableFuture +import java.util.concurrent.CompletableFuture +import kotlin.coroutines.CoroutineContext + +/** + * Created on 08.02.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +class FilmActorsFetcher( + private val resolver: FilmResolutionModel, + private val aspect: ResolutionAspect, + private val coroutineContextProvider: (DataFetchingEnvironment) -> CoroutineContext +) : DataFetcher>> { + override fun get(environment: DataFetchingEnvironment): CompletableFuture> { + val context = coroutineContextProvider(environment) + return CoroutineScope(context).async { + aspect.around(environment) { + resolver.actors(environment.getSource()!!) + } + }.asCompletableFuture() + } +} \ No newline at end of file diff --git a/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/runtime/fetcher/MutationCreateFilmFetcher.kt b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/runtime/fetcher/MutationCreateFilmFetcher.kt new file mode 100644 index 0000000..e982755 --- /dev/null +++ b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/runtime/fetcher/MutationCreateFilmFetcher.kt @@ -0,0 +1,34 @@ +package io.github.ermadmi78.kobby.cinema.api.kobby.server.runtime.fetcher + +import graphql.schema.DataFetcher +import graphql.schema.DataFetchingEnvironment +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.data.FilmData +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.resolver.MutationResolutionModel +import io.github.ermadmi78.kobby.cinema.api.kobby.server.runtime.ResolutionAspect +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.future.asCompletableFuture +import java.util.concurrent.CompletableFuture +import kotlin.coroutines.CoroutineContext + +/** + * Created on 08.02.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +class MutationCreateFilmFetcher( + private val resolver: MutationResolutionModel, + private val aspect: ResolutionAspect, + private val coroutineContextProvider: (DataFetchingEnvironment) -> CoroutineContext +) : DataFetcher> { + override fun get(environment: DataFetchingEnvironment): CompletableFuture { + val context = coroutineContextProvider(environment) + return CoroutineScope(context).async { + val argTitle: String = environment.getArgument("title") ?: error("Cannot find argument: argTitle") + + aspect.around(environment) { + resolver.createFilm(argTitle) + } + }.asCompletableFuture() + } +} \ No newline at end of file diff --git a/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/runtime/fetcher/QueryFilmFetcher.kt b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/runtime/fetcher/QueryFilmFetcher.kt new file mode 100644 index 0000000..f8c2811 --- /dev/null +++ b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/runtime/fetcher/QueryFilmFetcher.kt @@ -0,0 +1,34 @@ +package io.github.ermadmi78.kobby.cinema.api.kobby.server.runtime.fetcher + +import graphql.schema.DataFetcher +import graphql.schema.DataFetchingEnvironment +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.data.FilmData +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.resolver.QueryResolutionModel +import io.github.ermadmi78.kobby.cinema.api.kobby.server.runtime.ResolutionAspect +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.future.asCompletableFuture +import java.util.concurrent.CompletableFuture +import kotlin.coroutines.CoroutineContext + +/** + * Created on 08.02.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +class QueryFilmFetcher( + private val resolver: QueryResolutionModel, + private val aspect: ResolutionAspect, + private val coroutineContextProvider: (DataFetchingEnvironment) -> CoroutineContext +) : DataFetcher> { + override fun get(environment: DataFetchingEnvironment): CompletableFuture { + val context = coroutineContextProvider(environment) + return CoroutineScope(context).async { + val argId: Long = environment.getArgument("id") ?: error("Cannot find argument: id") + + aspect.around(environment) { + resolver.film(argId) + } + }.asCompletableFuture() + } +} \ No newline at end of file diff --git a/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/runtime/fetcher/QueryFilmsFetcher.kt b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/runtime/fetcher/QueryFilmsFetcher.kt new file mode 100644 index 0000000..c7ea703 --- /dev/null +++ b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/runtime/fetcher/QueryFilmsFetcher.kt @@ -0,0 +1,32 @@ +package io.github.ermadmi78.kobby.cinema.api.kobby.server.runtime.fetcher + +import graphql.schema.DataFetcher +import graphql.schema.DataFetchingEnvironment +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.data.FilmData +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.resolver.QueryResolutionModel +import io.github.ermadmi78.kobby.cinema.api.kobby.server.runtime.ResolutionAspect +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.future.asCompletableFuture +import java.util.concurrent.CompletableFuture +import kotlin.coroutines.CoroutineContext + +/** + * Created on 08.02.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +class QueryFilmsFetcher( + private val resolver: QueryResolutionModel, + private val aspect: ResolutionAspect, + private val coroutineContextProvider: (DataFetchingEnvironment) -> CoroutineContext +) : DataFetcher>> { + override fun get(environment: DataFetchingEnvironment): CompletableFuture> { + val context = coroutineContextProvider(environment) + return CoroutineScope(context).async { + aspect.around(environment) { + resolver.films() + } + }.asCompletableFuture() + } +} \ No newline at end of file diff --git a/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/runtime/fetcher/SubscriptionFilmCreatedFetcher.kt b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/runtime/fetcher/SubscriptionFilmCreatedFetcher.kt new file mode 100644 index 0000000..576e4eb --- /dev/null +++ b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/runtime/fetcher/SubscriptionFilmCreatedFetcher.kt @@ -0,0 +1,32 @@ +package io.github.ermadmi78.kobby.cinema.api.kobby.server.runtime.fetcher + +import graphql.schema.DataFetcher +import graphql.schema.DataFetchingEnvironment +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.data.FilmData +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.resolver.SubscriptionResolutionModel +import io.github.ermadmi78.kobby.cinema.api.kobby.server.runtime.ResolutionAspect +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.reactive.asPublisher +import org.reactivestreams.Publisher +import kotlin.coroutines.CoroutineContext + +/** + * Created on 08.02.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +class SubscriptionFilmCreatedFetcher( + private val resolver: SubscriptionResolutionModel, + private val aspect: ResolutionAspect, + private val coroutineContextProvider: (DataFetchingEnvironment) -> CoroutineContext +) : DataFetcher> { + override fun get(environment: DataFetchingEnvironment) = Publisher { subscriber -> + val context = coroutineContextProvider(environment) + CoroutineScope(context).launch { + aspect.around(environment) { + resolver.filmCreated() + }.asPublisher(context).subscribe(subscriber) + } + } +} diff --git a/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/runtime/utils.kt b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/runtime/utils.kt new file mode 100644 index 0000000..f07ba66 --- /dev/null +++ b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/runtime/utils.kt @@ -0,0 +1,25 @@ +package io.github.ermadmi78.kobby.cinema.api.kobby.server.runtime + +import graphql.schema.DataFetchingEnvironment +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlin.coroutines.CoroutineContext + +/** + * Created on 16.02.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ + +suspend fun resolutionEnvironment(): DataFetchingEnvironment = + currentCoroutineContext()[ResolutionContext.Key]?.environment + ?: error("Cinema resolution context is not configured") + +val DEFAULT_CONTEXT_PROVIDER: (DataFetchingEnvironment) -> CoroutineContext = { + Dispatchers.Default + ResolutionContext(it) +} + +val DEFAULT_RESOLUTION_ASPECT: ResolutionAspect = object : ResolutionAspect { + override suspend fun around(environment: DataFetchingEnvironment, resolution: suspend () -> T): T = + resolution() +} \ No newline at end of file diff --git a/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/coercing/BooleanCoercing.kt b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/coercing/BooleanCoercing.kt new file mode 100644 index 0000000..f86b460 --- /dev/null +++ b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/coercing/BooleanCoercing.kt @@ -0,0 +1,69 @@ +package io.github.ermadmi78.kobby.cinema.api.kobby.server.specification.coercing + +import graphql.GraphQLContext +import graphql.execution.CoercedVariables +import graphql.language.BooleanValue +import graphql.language.Value +import graphql.schema.Coercing +import graphql.schema.CoercingParseLiteralException +import graphql.schema.CoercingParseValueException +import graphql.schema.CoercingSerializeException +import java.math.BigDecimal +import java.math.BigInteger +import java.util.* + +/** + * Created on 15.03.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +class BooleanCoercing : Coercing { + private fun convert(input: Any): Boolean = when (input) { + is Boolean -> input + is String -> input.lowercase().toBooleanStrict() + is Int -> input != 0 + is Long -> input != 0L + is BigInteger -> input.compareTo(BigInteger.ZERO) != 0 + is BigDecimal -> input.compareTo(BigDecimal.ZERO) != 0 + is Number -> BigDecimal(input.toString()).compareTo(BigDecimal.ZERO) != 0 + else -> throw IllegalArgumentException("Unexpected input type: '${input::class.java.simpleName}'") + } + + @Throws(CoercingSerializeException::class) + override fun serialize( + input: Any, + graphQLContext: GraphQLContext, + locale: Locale + ): Boolean = try { + convert(input) + } catch (e: Exception) { + throw CoercingSerializeException("Expected type 'Boolean' but was '${input::class.java.simpleName}'", e) + } + + @Throws(CoercingParseValueException::class) + override fun parseValue( + input: Any, + graphQLContext: GraphQLContext, + locale: Locale + ): Boolean = try { + convert(input) + } catch (e: Exception) { + throw CoercingParseValueException("Expected type 'Boolean' but was '${input::class.java.simpleName}'", e) + } + + @Throws(CoercingParseLiteralException::class) + override fun parseLiteral( + input: Value<*>, + variables: CoercedVariables, + graphQLContext: GraphQLContext, + locale: Locale + ): Boolean { + if (input !is BooleanValue) { + throw CoercingParseLiteralException( + "Expected AST type 'BooleanValue' but was '${input::class.java.simpleName}'" + ) + } + + return input.isValue + } +} \ No newline at end of file diff --git a/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/coercing/DoubleCoercing.kt b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/coercing/DoubleCoercing.kt new file mode 100644 index 0000000..38e5550 --- /dev/null +++ b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/coercing/DoubleCoercing.kt @@ -0,0 +1,66 @@ +package io.github.ermadmi78.kobby.cinema.api.kobby.server.specification.coercing + +import graphql.GraphQLContext +import graphql.execution.CoercedVariables +import graphql.language.FloatValue +import graphql.language.IntValue +import graphql.language.Value +import graphql.schema.Coercing +import graphql.schema.CoercingParseLiteralException +import graphql.schema.CoercingParseValueException +import graphql.schema.CoercingSerializeException +import java.math.BigDecimal +import java.math.BigInteger +import java.util.* + +/** + * Created on 15.03.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +class DoubleCoercing : Coercing { + private fun convert(input: Any): Double = when (input) { + is Double -> input + is Float -> input.toDouble() + is BigInteger -> input.toDouble() + is BigDecimal -> input.toDouble() + is Number, String -> BigDecimal(input.toString()).toDouble() + else -> throw IllegalArgumentException("Unexpected input type: '${input::class.java.simpleName}'") + } + + @Throws(CoercingSerializeException::class) + override fun serialize( + input: Any, + graphQLContext: GraphQLContext, + locale: Locale + ): Double = try { + convert(input) + } catch (e: Exception) { + throw CoercingSerializeException("Expected type 'Double' but was '${input::class.java.simpleName}'", e) + } + + @Throws(CoercingParseValueException::class) + override fun parseValue( + input: Any, + graphQLContext: GraphQLContext, + locale: Locale + ): Double = try { + convert(input) + } catch (e: Exception) { + throw CoercingParseValueException("Expected type 'Double' but was '${input::class.java.simpleName}'", e) + } + + @Throws(CoercingParseLiteralException::class) + override fun parseLiteral( + input: Value<*>, + variables: CoercedVariables, + graphQLContext: GraphQLContext, + locale: Locale + ): Double = when (input) { + is FloatValue -> input.value.toDouble() + is IntValue -> input.value.toDouble() + else -> throw CoercingParseLiteralException( + "Expected AST type 'FloatValue' or 'IntValue' but was '${input::class.java.simpleName}'" + ) + } +} \ No newline at end of file diff --git a/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/coercing/IntCoercing.kt b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/coercing/IntCoercing.kt new file mode 100644 index 0000000..5d6390a --- /dev/null +++ b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/coercing/IntCoercing.kt @@ -0,0 +1,71 @@ +package io.github.ermadmi78.kobby.cinema.api.kobby.server.specification.coercing + +import graphql.GraphQLContext +import graphql.execution.CoercedVariables +import graphql.language.IntValue +import graphql.language.Value +import graphql.schema.Coercing +import graphql.schema.CoercingParseLiteralException +import graphql.schema.CoercingParseValueException +import graphql.schema.CoercingSerializeException +import java.math.BigDecimal +import java.math.BigInteger +import java.util.* + +/** + * Created on 15.03.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +class IntCoercing : Coercing { + private fun convert(input: Any): Int = when (input) { + is Int -> input + is BigInteger -> input.intValueExact() + is BigDecimal -> input.intValueExact() + is Number, String -> BigDecimal(input.toString()).intValueExact() + else -> throw IllegalArgumentException("Unexpected input type: '${input::class.java.simpleName}'") + } + + @Throws(CoercingSerializeException::class) + override fun serialize( + input: Any, + graphQLContext: GraphQLContext, + locale: Locale + ): Int = try { + convert(input) + } catch (e: Exception) { + throw CoercingSerializeException("Expected type 'Int' but was '${input::class.java.simpleName}'", e) + } + + @Throws(CoercingParseValueException::class) + override fun parseValue( + input: Any, + graphQLContext: GraphQLContext, + locale: Locale + ): Int = try { + convert(input) + } catch (e: Exception) { + throw CoercingParseValueException("Expected type 'Int' but was '${input::class.java.simpleName}'", e) + } + + @Throws(CoercingParseLiteralException::class) + override fun parseLiteral( + input: Value<*>, + variables: CoercedVariables, + graphQLContext: GraphQLContext, + locale: Locale + ): Int { + if (input !is IntValue) { + throw CoercingParseLiteralException( + "Expected AST type 'IntValue' but was '${input::class.java.simpleName}'" + ) + } + + val value: BigInteger = input.value + return try { + value.intValueExact() + } catch (e: Exception) { + throw CoercingParseLiteralException("Expected AST type 'IntValue' but was '$value'", e) + } + } +} \ No newline at end of file diff --git a/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/coercing/LongCoercing.kt b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/coercing/LongCoercing.kt new file mode 100644 index 0000000..5144f59 --- /dev/null +++ b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/coercing/LongCoercing.kt @@ -0,0 +1,72 @@ +package io.github.ermadmi78.kobby.cinema.api.kobby.server.specification.coercing + +import graphql.GraphQLContext +import graphql.execution.CoercedVariables +import graphql.language.IntValue +import graphql.language.Value +import graphql.schema.Coercing +import graphql.schema.CoercingParseLiteralException +import graphql.schema.CoercingParseValueException +import graphql.schema.CoercingSerializeException +import java.math.BigDecimal +import java.math.BigInteger +import java.util.* + +/** + * Created on 15.03.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +class LongCoercing : Coercing { + private fun convert(input: Any): Long = when (input) { + is Long -> input + is Int -> input.toLong() + is BigInteger -> input.longValueExact() + is BigDecimal -> input.longValueExact() + is Number, String -> BigDecimal(input.toString()).longValueExact() + else -> throw IllegalArgumentException("Unexpected input type: '${input::class.java.simpleName}'") + } + + @Throws(CoercingSerializeException::class) + override fun serialize( + input: Any, + graphQLContext: GraphQLContext, + locale: Locale + ): Long = try { + convert(input) + } catch (e: Exception) { + throw CoercingSerializeException("Expected type 'Long' but was '${input::class.java.simpleName}'", e) + } + + @Throws(CoercingParseValueException::class) + override fun parseValue( + input: Any, + graphQLContext: GraphQLContext, + locale: Locale + ): Long = try { + convert(input) + } catch (e: Exception) { + throw CoercingParseValueException("Expected type 'Long' but was '${input::class.java.simpleName}'", e) + } + + @Throws(CoercingParseLiteralException::class) + override fun parseLiteral( + input: Value<*>, + variables: CoercedVariables, + graphQLContext: GraphQLContext, + locale: Locale + ): Long { + if (input !is IntValue) { + throw CoercingParseLiteralException( + "Expected AST type 'IntValue' but was '${input::class.java.simpleName}'" + ) + } + + val value: BigInteger = input.value + return try { + value.longValueExact() + } catch (e: Exception) { + throw CoercingParseLiteralException("Expected AST type 'IntValue' but was '$value'", e) + } + } +} \ No newline at end of file diff --git a/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/coercing/StringCoercing.kt b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/coercing/StringCoercing.kt new file mode 100644 index 0000000..ad93c76 --- /dev/null +++ b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/coercing/StringCoercing.kt @@ -0,0 +1,48 @@ +package io.github.ermadmi78.kobby.cinema.api.kobby.server.specification.coercing + +import graphql.GraphQLContext +import graphql.execution.CoercedVariables +import graphql.language.StringValue +import graphql.language.Value +import graphql.schema.Coercing +import graphql.schema.CoercingParseLiteralException +import graphql.schema.CoercingParseValueException +import graphql.schema.CoercingSerializeException +import java.util.* + +/** + * Created on 15.03.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +class StringCoercing : Coercing { + @Throws(CoercingSerializeException::class) + override fun serialize( + input: Any, + graphQLContext: GraphQLContext, + locale: Locale + ): String = input.toString() + + @Throws(CoercingParseValueException::class) + override fun parseValue( + input: Any, + graphQLContext: GraphQLContext, + locale: Locale + ): String = input.toString() + + @Throws(CoercingParseLiteralException::class) + override fun parseLiteral( + input: Value<*>, + variables: CoercedVariables, + graphQLContext: GraphQLContext, + locale: Locale + ): String { + if (input !is StringValue) { + throw CoercingParseLiteralException( + "Expected AST type 'IntValue' but was '${input::class.java.simpleName}'" + ) + } + + return input.value + } +} \ No newline at end of file diff --git a/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/program/code/ActorCode.kt b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/program/code/ActorCode.kt new file mode 100644 index 0000000..7d24f79 --- /dev/null +++ b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/program/code/ActorCode.kt @@ -0,0 +1,31 @@ +package io.github.ermadmi78.kobby.cinema.api.kobby.server.specification.program.code + +import graphql.schema.FieldCoordinates.coordinates +import graphql.schema.GraphQLCodeRegistry +import graphql.schema.PropertyDataFetcher.fetching +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.data.ActorData + +/** + * Created on 22.02.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +internal object ActorCode { + fun register( + builder: GraphQLCodeRegistry.Builder + ) { + builder + .dataFetcher( + coordinates("Actor", "id"), + fetching { it.id } + ) + .dataFetcher( + coordinates("Actor", "firstName"), + fetching { it.firstName } + ) + .dataFetcher( + coordinates("Actor", "lastName"), + fetching { it.lastName } + ) + } +} \ No newline at end of file diff --git a/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/program/code/FilmCode.kt b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/program/code/FilmCode.kt new file mode 100644 index 0000000..200f59c --- /dev/null +++ b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/program/code/FilmCode.kt @@ -0,0 +1,39 @@ +package io.github.ermadmi78.kobby.cinema.api.kobby.server.specification.program.code + +import graphql.schema.DataFetchingEnvironment +import graphql.schema.FieldCoordinates.coordinates +import graphql.schema.GraphQLCodeRegistry +import graphql.schema.PropertyDataFetcher.fetching +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.data.FilmData +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.resolver.FilmResolutionModel +import io.github.ermadmi78.kobby.cinema.api.kobby.server.runtime.ResolutionAspect +import io.github.ermadmi78.kobby.cinema.api.kobby.server.runtime.fetcher.FilmActorsFetcher +import kotlin.coroutines.CoroutineContext + +/** + * Created on 22.02.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +internal object FilmCode { + fun register( + builder: GraphQLCodeRegistry.Builder, + resolver: FilmResolutionModel, + aspect: ResolutionAspect, + coroutineContextProvider: (DataFetchingEnvironment) -> CoroutineContext + ) { + builder + .dataFetcher( + coordinates("Film", "id"), + fetching { it.id } + ) + .dataFetcher( + coordinates("Film", "title"), + fetching { it.title } + ) + .dataFetcher( + coordinates("Film", "actors"), + FilmActorsFetcher(resolver, aspect, coroutineContextProvider) + ) + } +} \ No newline at end of file diff --git a/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/program/code/MutationCode.kt b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/program/code/MutationCode.kt new file mode 100644 index 0000000..0c67419 --- /dev/null +++ b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/program/code/MutationCode.kt @@ -0,0 +1,29 @@ +package io.github.ermadmi78.kobby.cinema.api.kobby.server.specification.program.code + +import graphql.schema.DataFetchingEnvironment +import graphql.schema.FieldCoordinates.coordinates +import graphql.schema.GraphQLCodeRegistry +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.resolver.MutationResolutionModel +import io.github.ermadmi78.kobby.cinema.api.kobby.server.runtime.ResolutionAspect +import io.github.ermadmi78.kobby.cinema.api.kobby.server.runtime.fetcher.MutationCreateFilmFetcher +import kotlin.coroutines.CoroutineContext + +/** + * Created on 15.02.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +internal object MutationCode { + fun register( + builder: GraphQLCodeRegistry.Builder, + resolver: MutationResolutionModel, + aspect: ResolutionAspect, + coroutineContextProvider: (DataFetchingEnvironment) -> CoroutineContext + ) { + builder + .dataFetcher( + coordinates("Mutation", "createFilm"), + MutationCreateFilmFetcher(resolver, aspect, coroutineContextProvider) + ) + } +} \ No newline at end of file diff --git a/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/program/code/QueryCode.kt b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/program/code/QueryCode.kt new file mode 100644 index 0000000..88320a1 --- /dev/null +++ b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/program/code/QueryCode.kt @@ -0,0 +1,34 @@ +package io.github.ermadmi78.kobby.cinema.api.kobby.server.specification.program.code + +import graphql.schema.DataFetchingEnvironment +import graphql.schema.FieldCoordinates.coordinates +import graphql.schema.GraphQLCodeRegistry +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.resolver.QueryResolutionModel +import io.github.ermadmi78.kobby.cinema.api.kobby.server.runtime.ResolutionAspect +import io.github.ermadmi78.kobby.cinema.api.kobby.server.runtime.fetcher.QueryFilmFetcher +import io.github.ermadmi78.kobby.cinema.api.kobby.server.runtime.fetcher.QueryFilmsFetcher +import kotlin.coroutines.CoroutineContext + +/** + * Created on 15.02.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +internal object QueryCode { + fun register( + builder: GraphQLCodeRegistry.Builder, + resolver: QueryResolutionModel, + aspect: ResolutionAspect, + coroutineContextProvider: (DataFetchingEnvironment) -> CoroutineContext + ) { + builder + .dataFetcher( + coordinates("Query", "film"), + QueryFilmFetcher(resolver, aspect, coroutineContextProvider) + ) + .dataFetcher( + coordinates("Query", "films"), + QueryFilmsFetcher(resolver, aspect, coroutineContextProvider) + ) + } +} \ No newline at end of file diff --git a/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/program/code/SubscriptionCode.kt b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/program/code/SubscriptionCode.kt new file mode 100644 index 0000000..f9e5792 --- /dev/null +++ b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/program/code/SubscriptionCode.kt @@ -0,0 +1,29 @@ +package io.github.ermadmi78.kobby.cinema.api.kobby.server.specification.program.code + +import graphql.schema.DataFetchingEnvironment +import graphql.schema.FieldCoordinates.coordinates +import graphql.schema.GraphQLCodeRegistry +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.resolver.SubscriptionResolutionModel +import io.github.ermadmi78.kobby.cinema.api.kobby.server.runtime.ResolutionAspect +import io.github.ermadmi78.kobby.cinema.api.kobby.server.runtime.fetcher.SubscriptionFilmCreatedFetcher +import kotlin.coroutines.CoroutineContext + +/** + * Created on 22.02.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +internal object SubscriptionCode { + fun register( + builder: GraphQLCodeRegistry.Builder, + resolver: SubscriptionResolutionModel, + aspect: ResolutionAspect, + coroutineContextProvider: (DataFetchingEnvironment) -> CoroutineContext + ) { + builder + .dataFetcher( + coordinates("Subscription", "filmCreated"), + SubscriptionFilmCreatedFetcher(resolver, aspect, coroutineContextProvider) + ) + } +} \ No newline at end of file diff --git a/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/program/type/ActorType.kt b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/program/type/ActorType.kt new file mode 100644 index 0000000..562fbd9 --- /dev/null +++ b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/program/type/ActorType.kt @@ -0,0 +1,32 @@ +package io.github.ermadmi78.kobby.cinema.api.kobby.server.specification.program.type + +import graphql.schema.GraphQLFieldDefinition +import graphql.schema.GraphQLNonNull.nonNull +import graphql.schema.GraphQLObjectType +import graphql.schema.GraphQLTypeReference.typeRef + +/** + * Created on 22.02.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +internal object ActorType { + fun build(): GraphQLObjectType = GraphQLObjectType.newObject() + .name("Actor") + .field( + GraphQLFieldDefinition.newFieldDefinition() + .name("id") + .type(nonNull(typeRef("ID"))) + ) + .field( + GraphQLFieldDefinition.newFieldDefinition() + .name("firstName") + .type(nonNull(typeRef("String"))) + ) + .field( + GraphQLFieldDefinition.newFieldDefinition() + .name("lastName") + .type(typeRef("String")) + ) + .build() +} \ No newline at end of file diff --git a/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/program/type/FilmType.kt b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/program/type/FilmType.kt new file mode 100644 index 0000000..99fa271 --- /dev/null +++ b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/program/type/FilmType.kt @@ -0,0 +1,33 @@ +package io.github.ermadmi78.kobby.cinema.api.kobby.server.specification.program.type + +import graphql.schema.GraphQLFieldDefinition +import graphql.schema.GraphQLList.list +import graphql.schema.GraphQLNonNull.nonNull +import graphql.schema.GraphQLObjectType +import graphql.schema.GraphQLTypeReference.typeRef + +/** + * Created on 22.02.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +internal object FilmType { + fun build(): GraphQLObjectType = GraphQLObjectType.newObject() + .name("Film") + .field( + GraphQLFieldDefinition.newFieldDefinition() + .name("id") + .type(nonNull(typeRef("ID"))) + ) + .field( + GraphQLFieldDefinition.newFieldDefinition() + .name("title") + .type(nonNull(typeRef("String"))) + ) + .field( + GraphQLFieldDefinition.newFieldDefinition() + .name("actors") + .type(nonNull(list(nonNull(typeRef("Actor"))))) + ) + .build() +} \ No newline at end of file diff --git a/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/program/type/MutationType.kt b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/program/type/MutationType.kt new file mode 100644 index 0000000..4730674 --- /dev/null +++ b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/program/type/MutationType.kt @@ -0,0 +1,28 @@ +package io.github.ermadmi78.kobby.cinema.api.kobby.server.specification.program.type + +import graphql.schema.GraphQLArgument +import graphql.schema.GraphQLFieldDefinition +import graphql.schema.GraphQLNonNull.nonNull +import graphql.schema.GraphQLObjectType +import graphql.schema.GraphQLTypeReference.typeRef + +/** + * Created on 15.02.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +internal object MutationType { + fun build(): GraphQLObjectType = GraphQLObjectType.newObject() + .name("Mutation") + .field( + GraphQLFieldDefinition.newFieldDefinition() + .name("createFilm") + .type(nonNull(typeRef("Film"))) + .argument( + GraphQLArgument.newArgument() + .name("title") + .type(nonNull(typeRef("String"))) + ) + ) + .build() +} \ No newline at end of file diff --git a/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/program/type/QueryType.kt b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/program/type/QueryType.kt new file mode 100644 index 0000000..8b1b38e --- /dev/null +++ b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/program/type/QueryType.kt @@ -0,0 +1,34 @@ +package io.github.ermadmi78.kobby.cinema.api.kobby.server.specification.program.type + +import graphql.schema.GraphQLArgument +import graphql.schema.GraphQLFieldDefinition +import graphql.schema.GraphQLList.list +import graphql.schema.GraphQLNonNull.nonNull +import graphql.schema.GraphQLObjectType +import graphql.schema.GraphQLTypeReference.typeRef + +/** + * Created on 15.02.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +internal object QueryType { + fun build(): GraphQLObjectType = GraphQLObjectType.newObject() + .name("Query") + .field( + GraphQLFieldDefinition.newFieldDefinition() + .name("film") + .type(typeRef("Film")) + .argument( + GraphQLArgument.newArgument() + .name("id") + .type(nonNull(typeRef("ID"))) + ) + ) + .field( + GraphQLFieldDefinition.newFieldDefinition() + .name("films") + .type(nonNull(list(nonNull(typeRef("Film"))))) + ) + .build() +} \ No newline at end of file diff --git a/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/program/type/SubscriptionType.kt b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/program/type/SubscriptionType.kt new file mode 100644 index 0000000..da841e6 --- /dev/null +++ b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/program/type/SubscriptionType.kt @@ -0,0 +1,22 @@ +package io.github.ermadmi78.kobby.cinema.api.kobby.server.specification.program.type + +import graphql.schema.GraphQLFieldDefinition +import graphql.schema.GraphQLNonNull.nonNull +import graphql.schema.GraphQLObjectType +import graphql.schema.GraphQLTypeReference.typeRef + +/** + * Created on 22.02.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +internal object SubscriptionType { + fun build(): GraphQLObjectType = GraphQLObjectType.newObject() + .name("Subscription") + .field( + GraphQLFieldDefinition.newFieldDefinition() + .name("filmCreated") + .type(nonNull(typeRef("Film"))) + ) + .build() +} \ No newline at end of file diff --git a/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/scalar.kt b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/scalar.kt new file mode 100644 index 0000000..279c9be --- /dev/null +++ b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/scalar.kt @@ -0,0 +1,52 @@ +package io.github.ermadmi78.kobby.cinema.api.kobby.server.specification + +import graphql.schema.GraphQLScalarType +import io.github.ermadmi78.kobby.cinema.api.kobby.server.specification.coercing.* + +/** + * Created on 15.03.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ + +val DEDAULT_ID_SCALAR = GraphQLScalarType.newScalar() + .name("ID") + .description("Cinema ID scalar") + .coercing(LongCoercing()) + .build() + +val DEDAULT_INT_SCALAR = GraphQLScalarType.newScalar() + .name("Int") + .description("Cinema Int scalar") + .coercing(IntCoercing()) + .build() + +val DEDAULT_LONG_SCALAR = GraphQLScalarType.newScalar() + .name("Long") + .description("Cinema Long scalar") + .coercing(LongCoercing()) + .build() + +val DEDAULT_FLOAT_SCALAR = GraphQLScalarType.newScalar() + .name("Float") + .description("Cinema Float scalar") + .coercing(DoubleCoercing()) + .build() + +val DEDAULT_DOUBLE_SCALAR = GraphQLScalarType.newScalar() + .name("Double") + .description("Cinema Double scalar") + .coercing(DoubleCoercing()) + .build() + +val DEDAULT_STRING_SCALAR = GraphQLScalarType.newScalar() + .name("String") + .description("Cinema String scalar") + .coercing(StringCoercing()) + .build() + +val DEDAULT_BOOLEAN_SCALAR = GraphQLScalarType.newScalar() + .name("Boolean") + .description("Cinema Boolean scalar") + .coercing(BooleanCoercing()) + .build() \ No newline at end of file diff --git a/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/sdl/ActorWiring.kt b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/sdl/ActorWiring.kt new file mode 100644 index 0000000..8a8568f --- /dev/null +++ b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/sdl/ActorWiring.kt @@ -0,0 +1,33 @@ +package io.github.ermadmi78.kobby.cinema.api.kobby.server.specification.sdl + +import graphql.schema.PropertyDataFetcher.fetching +import graphql.schema.idl.RuntimeWiring +import graphql.schema.idl.TypeRuntimeWiring.newTypeWiring +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.data.ActorData + +/** + * Created on 22.02.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +internal object ActorWiring { + fun register( + builder: RuntimeWiring.Builder + ) { + builder.type( + newTypeWiring("Actor") + .dataFetcher( + "id", + fetching { it.id } + ) + .dataFetcher( + "firstName", + fetching { it.firstName } + ) + .dataFetcher( + "lastName", + fetching { it.lastName } + ) + ) + } +} \ No newline at end of file diff --git a/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/sdl/FilmWiring.kt b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/sdl/FilmWiring.kt new file mode 100644 index 0000000..1f93b1b --- /dev/null +++ b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/sdl/FilmWiring.kt @@ -0,0 +1,41 @@ +package io.github.ermadmi78.kobby.cinema.api.kobby.server.specification.sdl + +import graphql.schema.DataFetchingEnvironment +import graphql.schema.PropertyDataFetcher.fetching +import graphql.schema.idl.RuntimeWiring +import graphql.schema.idl.TypeRuntimeWiring.newTypeWiring +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.data.FilmData +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.resolver.FilmResolutionModel +import io.github.ermadmi78.kobby.cinema.api.kobby.server.runtime.ResolutionAspect +import io.github.ermadmi78.kobby.cinema.api.kobby.server.runtime.fetcher.FilmActorsFetcher +import kotlin.coroutines.CoroutineContext + +/** + * Created on 22.02.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +internal object FilmWiring { + fun register( + builder: RuntimeWiring.Builder, + resolver: FilmResolutionModel, + aspect: ResolutionAspect, + coroutineContextProvider: (DataFetchingEnvironment) -> CoroutineContext + ) { + builder.type( + newTypeWiring("Film") + .dataFetcher( + "id", + fetching { it.id } + ) + .dataFetcher( + "title", + fetching { it.title } + ) + .dataFetcher( + "actors", + FilmActorsFetcher(resolver, aspect, coroutineContextProvider) + ) + ) + } +} \ No newline at end of file diff --git a/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/sdl/MutationWiring.kt b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/sdl/MutationWiring.kt new file mode 100644 index 0000000..9656b7a --- /dev/null +++ b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/sdl/MutationWiring.kt @@ -0,0 +1,31 @@ +package io.github.ermadmi78.kobby.cinema.api.kobby.server.specification.sdl + +import graphql.schema.DataFetchingEnvironment +import graphql.schema.idl.RuntimeWiring +import graphql.schema.idl.TypeRuntimeWiring.newTypeWiring +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.resolver.MutationResolutionModel +import io.github.ermadmi78.kobby.cinema.api.kobby.server.runtime.ResolutionAspect +import io.github.ermadmi78.kobby.cinema.api.kobby.server.runtime.fetcher.MutationCreateFilmFetcher +import kotlin.coroutines.CoroutineContext + +/** + * Created on 22.02.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +internal object MutationWiring { + fun register( + builder: RuntimeWiring.Builder, + resolver: MutationResolutionModel, + aspect: ResolutionAspect, + coroutineContextProvider: (DataFetchingEnvironment) -> CoroutineContext + ) { + builder.type( + newTypeWiring("Mutation") + .dataFetcher( + "createFilm", + MutationCreateFilmFetcher(resolver, aspect, coroutineContextProvider) + ) + ) + } +} \ No newline at end of file diff --git a/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/sdl/QueryWiring.kt b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/sdl/QueryWiring.kt new file mode 100644 index 0000000..03d343c --- /dev/null +++ b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/sdl/QueryWiring.kt @@ -0,0 +1,36 @@ +package io.github.ermadmi78.kobby.cinema.api.kobby.server.specification.sdl + +import graphql.schema.DataFetchingEnvironment +import graphql.schema.idl.RuntimeWiring +import graphql.schema.idl.TypeRuntimeWiring.newTypeWiring +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.resolver.QueryResolutionModel +import io.github.ermadmi78.kobby.cinema.api.kobby.server.runtime.ResolutionAspect +import io.github.ermadmi78.kobby.cinema.api.kobby.server.runtime.fetcher.QueryFilmFetcher +import io.github.ermadmi78.kobby.cinema.api.kobby.server.runtime.fetcher.QueryFilmsFetcher +import kotlin.coroutines.CoroutineContext + +/** + * Created on 22.02.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +internal object QueryWiring { + fun register( + builder: RuntimeWiring.Builder, + resolver: QueryResolutionModel, + aspect: ResolutionAspect, + coroutineContextProvider: (DataFetchingEnvironment) -> CoroutineContext + ) { + builder.type( + newTypeWiring("Query") + .dataFetcher( + "film", + QueryFilmFetcher(resolver, aspect, coroutineContextProvider) + ) + .dataFetcher( + "films", + QueryFilmsFetcher(resolver, aspect, coroutineContextProvider) + ) + ) + } +} \ No newline at end of file diff --git a/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/sdl/SubscriptionWiring.kt b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/sdl/SubscriptionWiring.kt new file mode 100644 index 0000000..ef8c82b --- /dev/null +++ b/cinema-api/src/main/kotlin/io/github/ermadmi78/kobby/cinema/api/kobby/server/specification/sdl/SubscriptionWiring.kt @@ -0,0 +1,31 @@ +package io.github.ermadmi78.kobby.cinema.api.kobby.server.specification.sdl + +import graphql.schema.DataFetchingEnvironment +import graphql.schema.idl.RuntimeWiring +import graphql.schema.idl.TypeRuntimeWiring.newTypeWiring +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.resolver.SubscriptionResolutionModel +import io.github.ermadmi78.kobby.cinema.api.kobby.server.runtime.ResolutionAspect +import io.github.ermadmi78.kobby.cinema.api.kobby.server.runtime.fetcher.SubscriptionFilmCreatedFetcher +import kotlin.coroutines.CoroutineContext + +/** + * Created on 22.02.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +internal object SubscriptionWiring { + fun register( + builder: RuntimeWiring.Builder, + resolver: SubscriptionResolutionModel, + aspect: ResolutionAspect, + coroutineContextProvider: (DataFetchingEnvironment) -> CoroutineContext + ) { + builder.type( + newTypeWiring("Subscription") + .dataFetcher( + "filmCreated", + SubscriptionFilmCreatedFetcher(resolver, aspect, coroutineContextProvider) + ) + ) + } +} \ No newline at end of file diff --git a/cinema-api/src/main/resources/io/github/ermadmi78/kobby/cinema/api/cinema.graphqls b/cinema-api/src/main/resources/io/github/ermadmi78/kobby/cinema/api/cinema.graphqls index a14292e..06aa0e2 100644 --- a/cinema-api/src/main/resources/io/github/ermadmi78/kobby/cinema/api/cinema.graphqls +++ b/cinema-api/src/main/resources/io/github/ermadmi78/kobby/cinema/api/cinema.graphqls @@ -1,3 +1,5 @@ +directive @resolve on FIELD_DEFINITION + type Query { film(id: ID!): Film films: [Film!]! @@ -14,7 +16,7 @@ type Subscription { type Film { id: ID! title: String! - actors: [Actor!]! + actors: [Actor!]! @resolve } type Actor { diff --git a/cinema-server/build.gradle.kts b/cinema-server/build.gradle.kts index 7383231..c3e5cec 100644 --- a/cinema-server/build.gradle.kts +++ b/cinema-server/build.gradle.kts @@ -1,3 +1,4 @@ +import org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL import org.springframework.boot.gradle.plugin.SpringBootPlugin description = "Cinema Server" @@ -12,12 +13,21 @@ kotlin { jvmToolchain(17) } +tasks { + test { + testLogging.showStandardStreams = true + testLogging.exceptionFormat = FULL + useJUnitPlatform() + } +} + dependencies { implementation(project(":cinema-api")) implementation(platform(SpringBootPlugin.BOM_COORDINATES)) implementation("org.springframework.boot:spring-boot-starter-graphql") implementation("org.springframework.boot:spring-boot-starter-webflux") + implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.data:spring-data-commons") implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.15.4") @@ -27,4 +37,15 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.8.1") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactive:1.8.1") + + implementation("com.graphql-java:graphql-java-extended-scalars:24.0") + + implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.15.4") + implementation("com.fasterxml.jackson.module:jackson-module-parameter-names:2.15.4") + + testImplementation("io.kotest:kotest-runner-junit5:5.8.0") + testImplementation("io.kotest:kotest-assertions-core:5.8.0") + testImplementation("io.kotest:kotest-property:5.8.0") + //testImplementation("io.kotest.extensions:kotest-extensions-spring:1.1.3") } \ No newline at end of file diff --git a/cinema-server/src/main/kotlin/io/github/ermadmi78/kobby/cinema/server/ApplicationConfiguration.kt b/cinema-server/src/main/kotlin/io/github/ermadmi78/kobby/cinema/server/ApplicationConfiguration.kt new file mode 100644 index 0000000..f1f1d61 --- /dev/null +++ b/cinema-server/src/main/kotlin/io/github/ermadmi78/kobby/cinema/server/ApplicationConfiguration.kt @@ -0,0 +1,66 @@ +package io.github.ermadmi78.kobby.cinema.server + +import io.github.ermadmi78.kobby.cinema.api.kobby.server.wireCinemaSchemaRuntime +import io.github.ermadmi78.kobby.cinema.server.resolver.* +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.graphql.execution.RuntimeWiringConfigurer +import org.springframework.security.config.web.server.ServerHttpSecurity +import org.springframework.security.core.userdetails.MapReactiveUserDetailsService +import org.springframework.security.core.userdetails.ReactiveUserDetailsService +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.web.server.SecurityWebFilterChain +import org.springframework.web.reactive.config.EnableWebFlux + +/** + * Created on 23.02.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +@Configuration +@EnableWebFlux +class ApplicationConfiguration() { + @Bean + fun runtimeWiringConfigurer( + queryResolver: QueryResolver, + mutationResolver: MutationResolver, + subscriptionResolver: SubscriptionResolver, + filmResolver: FilmResolver, + cinemaResolutionAspect: CinemaResolutionAspect + ) = RuntimeWiringConfigurer { builder -> + wireCinemaSchemaRuntime( + runtimeWiring = builder, + queryResolver = queryResolver, + mutationResolver = mutationResolver, + subscriptionResolver = subscriptionResolver, + filmResolver = filmResolver, + aspect = cinemaResolutionAspect + ) + } + + @Bean + fun securityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain = http + .csrf { it.disable() } + .authorizeExchange { it.anyExchange().authenticated() } + .httpBasic {} + .build() + + /** + * Basic HTTP users configuration + */ + @Bean + fun userDetailsService(): ReactiveUserDetailsService { + val user: UserDetails = User.builder() + .username("user") + .password("{noop}user") + .roles("USER") + .build() + val admin: UserDetails = User.builder() + .username("admin") + .password("{noop}admin") + .roles("ADMIN") + .build() + return MapReactiveUserDetailsService(user, admin) + } +} diff --git a/cinema-server/src/main/kotlin/io/github/ermadmi78/kobby/cinema/server/controller/MutationController.kt b/cinema-server/src/main/kotlin/io/github/ermadmi78/kobby/cinema/server/controller/MutationController.kt deleted file mode 100644 index 4fe2576..0000000 --- a/cinema-server/src/main/kotlin/io/github/ermadmi78/kobby/cinema/server/controller/MutationController.kt +++ /dev/null @@ -1,25 +0,0 @@ -package io.github.ermadmi78.kobby.cinema.server.controller - -import io.github.ermadmi78.kobby.cinema.api.kobby.kotlin.dto.FilmDto -import io.github.ermadmi78.kobby.cinema.server.dao.CinemaDao -import io.github.ermadmi78.kobby.cinema.server.eventbus.EventBus -import org.springframework.graphql.data.method.annotation.Argument -import org.springframework.graphql.data.method.annotation.SchemaMapping -import org.springframework.stereotype.Controller - -/** - * Created on 16.10.2021 - * - * @author Dmitry Ermakov (ermadmi78@gmail.com) - */ -@Controller -@SchemaMapping(typeName = "Mutation") -class MutationController( - private val cinemaDao: CinemaDao, - private val eventBus: EventBus -) { - @SchemaMapping - suspend fun createFilm(@Argument title: String): FilmDto = cinemaDao.createFilm(title).also { - eventBus.fireFilmCreated(it) - } -} \ No newline at end of file diff --git a/cinema-server/src/main/kotlin/io/github/ermadmi78/kobby/cinema/server/controller/QueryController.kt b/cinema-server/src/main/kotlin/io/github/ermadmi78/kobby/cinema/server/controller/QueryController.kt deleted file mode 100644 index 0a5d62e..0000000 --- a/cinema-server/src/main/kotlin/io/github/ermadmi78/kobby/cinema/server/controller/QueryController.kt +++ /dev/null @@ -1,24 +0,0 @@ -package io.github.ermadmi78.kobby.cinema.server.controller - -import io.github.ermadmi78.kobby.cinema.api.kobby.kotlin.dto.FilmDto -import io.github.ermadmi78.kobby.cinema.server.dao.CinemaDao -import org.springframework.graphql.data.method.annotation.Argument -import org.springframework.graphql.data.method.annotation.SchemaMapping -import org.springframework.stereotype.Controller - -/** - * Created on 16.10.2021 - * - * @author Dmitry Ermakov (ermadmi78@gmail.com) - */ -@Controller -@SchemaMapping(typeName = "Query") -class QueryController( - private val cinemaDao: CinemaDao -) { - @SchemaMapping - suspend fun film(@Argument id: Long): FilmDto? = cinemaDao.findFilm(id) - - @SchemaMapping - suspend fun films(): List = cinemaDao.findFilms() -} \ No newline at end of file diff --git a/cinema-server/src/main/kotlin/io/github/ermadmi78/kobby/cinema/server/controller/SubscriptionController.kt b/cinema-server/src/main/kotlin/io/github/ermadmi78/kobby/cinema/server/controller/SubscriptionController.kt deleted file mode 100644 index 34d8820..0000000 --- a/cinema-server/src/main/kotlin/io/github/ermadmi78/kobby/cinema/server/controller/SubscriptionController.kt +++ /dev/null @@ -1,22 +0,0 @@ -package io.github.ermadmi78.kobby.cinema.server.controller - -import io.github.ermadmi78.kobby.cinema.api.kobby.kotlin.dto.FilmDto -import io.github.ermadmi78.kobby.cinema.server.eventbus.EventBus -import kotlinx.coroutines.reactor.asFlux -import org.springframework.graphql.data.method.annotation.SchemaMapping -import org.springframework.stereotype.Controller -import reactor.core.publisher.Flux - -/** - * Created on 16.10.2021 - * - * @author Dmitry Ermakov (ermadmi78@gmail.com) - */ -@Controller -@SchemaMapping(typeName = "Subscription") -class SubscriptionController( - private val eventBus: EventBus -) { - @SchemaMapping - suspend fun filmCreated(): Flux = eventBus.filmCreatedFlow().asFlux() -} \ No newline at end of file diff --git a/cinema-server/src/main/kotlin/io/github/ermadmi78/kobby/cinema/server/dao/ActorRecord.kt b/cinema-server/src/main/kotlin/io/github/ermadmi78/kobby/cinema/server/dao/ActorRecord.kt new file mode 100644 index 0000000..cda2bda --- /dev/null +++ b/cinema-server/src/main/kotlin/io/github/ermadmi78/kobby/cinema/server/dao/ActorRecord.kt @@ -0,0 +1,12 @@ +package io.github.ermadmi78.kobby.cinema.server.dao + +/** + * Created on 09.03.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +data class ActorRecord( + val id: Long? = null, + val firstName: String? = null, + val lastName: String? = null, +) diff --git a/cinema-server/src/main/kotlin/io/github/ermadmi78/kobby/cinema/server/dao/CinemaDao.kt b/cinema-server/src/main/kotlin/io/github/ermadmi78/kobby/cinema/server/dao/CinemaDao.kt index 17ae577..0946e14 100644 --- a/cinema-server/src/main/kotlin/io/github/ermadmi78/kobby/cinema/server/dao/CinemaDao.kt +++ b/cinema-server/src/main/kotlin/io/github/ermadmi78/kobby/cinema/server/dao/CinemaDao.kt @@ -1,7 +1,5 @@ package io.github.ermadmi78.kobby.cinema.server.dao -import io.github.ermadmi78.kobby.cinema.api.kobby.kotlin.dto.ActorDto -import io.github.ermadmi78.kobby.cinema.api.kobby.kotlin.dto.FilmDto import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.springframework.stereotype.Component @@ -16,32 +14,43 @@ class CinemaDao { private val mutex = Mutex() private val actors = mapOf( - 0L to ActorDto(0L, "Audrey", "Tautou"), - 1L to ActorDto(1L, "Mathieu", "Kassovitz"), - 2L to ActorDto(2L, "Jamel", "Debbouze"), - 3L to ActorDto(3L, "Dominique", "Pinon"), - 4L to ActorDto(4L, "Gaspard", "Ulliel"), - 5L to ActorDto(5L, "Guillaume", "Canet"), - 6L to ActorDto(6L, "Gad", "Elmaleh") + 0L to ActorRecord(0L, "Audrey", "Tautou"), + 1L to ActorRecord(1L, "Mathieu", "Kassovitz"), + 2L to ActorRecord(2L, "Jamel", "Debbouze"), + 3L to ActorRecord(3L, "Dominique", "Pinon"), + 4L to ActorRecord(4L, "Gaspard", "Ulliel"), + 5L to ActorRecord(5L, "Guillaume", "Canet"), + 6L to ActorRecord(6L, "Gad", "Elmaleh") ) private val films = mutableMapOf( - 0L to FilmDto(0L, "Amelie", listOf(actors[0L]!!, actors[1L]!!, actors[2L]!!, actors[3L]!!)), - 1L to FilmDto(1L, "A Very Long Engagement", listOf(actors[0L]!!, actors[3L]!!, actors[4L]!!)), - 2L to FilmDto(2L, "Hunting and Gathering", listOf(actors[0L]!!, actors[5L]!!)), - 3L to FilmDto(3L, "Priceless", listOf(actors[0L]!!, actors[6L]!!)) + 0L to FilmRecord(0L, "Amelie"), + 1L to FilmRecord(1L, "A Very Long Engagement"), + 2L to FilmRecord(2L, "Hunting and Gathering"), + 3L to FilmRecord(3L, "Priceless") ) - suspend fun findFilm(id: Long): FilmDto? = mutex.withLock { + private val filmActors = mutableMapOf( + 0L to listOf(actors[0L]!!, actors[1L]!!, actors[2L]!!, actors[3L]!!), + 1L to listOf(actors[0L]!!, actors[3L]!!, actors[4L]!!), + 2L to listOf(actors[0L]!!, actors[5L]!!), + 3L to listOf(actors[0L]!!, actors[6L]!!) + ) + + suspend fun findFilm(id: Long): FilmRecord? = mutex.withLock { films[id] } - suspend fun findFilms(): List = mutex.withLock { + suspend fun findFilms(): List = mutex.withLock { films.values.toList() } - suspend fun createFilm(title: String): FilmDto = mutex.withLock { - FilmDto(films.size.toLong(), title, listOf()).also { + suspend fun findFilmActors(id: Long): List = mutex.withLock { + filmActors[id] ?: emptyList() + } + + suspend fun createFilm(title: String): FilmRecord = mutex.withLock { + FilmRecord(films.size.toLong(), title).also { films[it.id!!] = it } } diff --git a/cinema-server/src/main/kotlin/io/github/ermadmi78/kobby/cinema/server/dao/FilmRecord.kt b/cinema-server/src/main/kotlin/io/github/ermadmi78/kobby/cinema/server/dao/FilmRecord.kt new file mode 100644 index 0000000..4f50ab6 --- /dev/null +++ b/cinema-server/src/main/kotlin/io/github/ermadmi78/kobby/cinema/server/dao/FilmRecord.kt @@ -0,0 +1,11 @@ +package io.github.ermadmi78.kobby.cinema.server.dao + +/** + * Created on 09.03.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +data class FilmRecord( + val id: Long? = null, + val title: String? = null, +) diff --git a/cinema-server/src/main/kotlin/io/github/ermadmi78/kobby/cinema/server/eventbus/EventBus.kt b/cinema-server/src/main/kotlin/io/github/ermadmi78/kobby/cinema/server/eventbus/EventBus.kt index 305323b..72c3172 100644 --- a/cinema-server/src/main/kotlin/io/github/ermadmi78/kobby/cinema/server/eventbus/EventBus.kt +++ b/cinema-server/src/main/kotlin/io/github/ermadmi78/kobby/cinema/server/eventbus/EventBus.kt @@ -1,6 +1,7 @@ package io.github.ermadmi78.kobby.cinema.server.eventbus -import io.github.ermadmi78.kobby.cinema.api.kobby.kotlin.dto.FilmDto +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.data.FilmData +import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -13,15 +14,16 @@ import org.springframework.stereotype.Component * @author Dmitry Ermakov (ermadmi78@gmail.com) */ @Component +@OptIn(DelicateCoroutinesApi::class) class EventBus { - private val filmCreatedBus: MutableSharedFlow = MutableSharedFlow( + private val filmCreatedBus: MutableSharedFlow = MutableSharedFlow( extraBufferCapacity = 10, onBufferOverflow = BufferOverflow.DROP_OLDEST ) - suspend fun fireFilmCreated(film: FilmDto) = + suspend fun fireFilmCreated(film: FilmData) = filmCreatedBus.emit(film) - fun filmCreatedFlow(): Flow = + fun filmCreatedFlow(): Flow = filmCreatedBus.asSharedFlow() } \ No newline at end of file diff --git a/cinema-server/src/main/kotlin/io/github/ermadmi78/kobby/cinema/server/resolver/CinemaResolutionAspect.kt b/cinema-server/src/main/kotlin/io/github/ermadmi78/kobby/cinema/server/resolver/CinemaResolutionAspect.kt new file mode 100644 index 0000000..35747d8 --- /dev/null +++ b/cinema-server/src/main/kotlin/io/github/ermadmi78/kobby/cinema/server/resolver/CinemaResolutionAspect.kt @@ -0,0 +1,45 @@ +package io.github.ermadmi78.kobby.cinema.server.resolver + +import graphql.schema.DataFetchingEnvironment +import graphql.schema.GraphQLNamedSchemaElement +import io.github.ermadmi78.kobby.cinema.api.kobby.server.runtime.ResolutionAspect +import kotlinx.coroutines.future.asDeferred +import org.springframework.security.access.AccessDeniedException +import org.springframework.security.core.Authentication +import org.springframework.security.core.context.SecurityContext +import org.springframework.stereotype.Component +import reactor.core.publisher.Mono + +/** + * Created on 23.02.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +@Component +class CinemaResolutionAspect : ResolutionAspect { + override suspend fun around( + environment: DataFetchingEnvironment, + resolution: suspend () -> T + ): T { + if (environment.getSource() != null) { + return resolution() // Not a root resolver — just execute it. + } + + val securityContextProvider: Mono? = environment.graphQlContext + .get>(SecurityContext::class.java) + + val authentication: Authentication = securityContextProvider?.toFuture()?.asDeferred()?.await() + ?.authentication ?: throw AccessDeniedException("Not authorized") + + val start = System.currentTimeMillis() + val result = resolution() + + println( + "[${authentication.name}]: " + + "${(environment.parentType as? GraphQLNamedSchemaElement)?.name}.${environment.field?.name} " + + "${System.currentTimeMillis() - start} millis" + ) + + return result + } +} \ No newline at end of file diff --git a/cinema-server/src/main/kotlin/io/github/ermadmi78/kobby/cinema/server/resolver/FilmResolver.kt b/cinema-server/src/main/kotlin/io/github/ermadmi78/kobby/cinema/server/resolver/FilmResolver.kt new file mode 100644 index 0000000..a77069c --- /dev/null +++ b/cinema-server/src/main/kotlin/io/github/ermadmi78/kobby/cinema/server/resolver/FilmResolver.kt @@ -0,0 +1,22 @@ +package io.github.ermadmi78.kobby.cinema.server.resolver + +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.data.ActorData +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.data.FilmData +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.resolver.FilmResolutionModel +import io.github.ermadmi78.kobby.cinema.server.dao.CinemaDao +import org.springframework.stereotype.Component + +/** + * Created on 23.02.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +@Component +class FilmResolver( + private val cinemaDao: CinemaDao +) : FilmResolutionModel { + override suspend fun actors(source: FilmData): List = + cinemaDao.findFilmActors(source.id!!).map { + ActorData(it.id, it.firstName, it.lastName) + } +} \ No newline at end of file diff --git a/cinema-server/src/main/kotlin/io/github/ermadmi78/kobby/cinema/server/resolver/MutationResolver.kt b/cinema-server/src/main/kotlin/io/github/ermadmi78/kobby/cinema/server/resolver/MutationResolver.kt new file mode 100644 index 0000000..09a2ae6 --- /dev/null +++ b/cinema-server/src/main/kotlin/io/github/ermadmi78/kobby/cinema/server/resolver/MutationResolver.kt @@ -0,0 +1,24 @@ +package io.github.ermadmi78.kobby.cinema.server.resolver + +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.data.FilmData +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.resolver.MutationResolutionModel +import io.github.ermadmi78.kobby.cinema.server.dao.CinemaDao +import io.github.ermadmi78.kobby.cinema.server.eventbus.EventBus +import org.springframework.stereotype.Component + +/** + * Created on 23.02.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +@Component +class MutationResolver( + private val cinemaDao: CinemaDao, + private val eventBus: EventBus +) : MutationResolutionModel { + override suspend fun createFilm(title: String): FilmData = cinemaDao.createFilm(title).let { + FilmData(it.id, it.title) + }.also { + eventBus.fireFilmCreated(it) + } +} \ No newline at end of file diff --git a/cinema-server/src/main/kotlin/io/github/ermadmi78/kobby/cinema/server/resolver/QueryResolver.kt b/cinema-server/src/main/kotlin/io/github/ermadmi78/kobby/cinema/server/resolver/QueryResolver.kt new file mode 100644 index 0000000..124c111 --- /dev/null +++ b/cinema-server/src/main/kotlin/io/github/ermadmi78/kobby/cinema/server/resolver/QueryResolver.kt @@ -0,0 +1,24 @@ +package io.github.ermadmi78.kobby.cinema.server.resolver + +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.data.FilmData +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.resolver.QueryResolutionModel +import io.github.ermadmi78.kobby.cinema.server.dao.CinemaDao +import org.springframework.stereotype.Component + +/** + * Created on 23.02.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +@Component +class QueryResolver( + private val cinemaDao: CinemaDao +) : QueryResolutionModel { + override suspend fun film(id: Long): FilmData? = cinemaDao.findFilm(id)?.let { + FilmData(it.id, it.title) + } + + override suspend fun films(): List = cinemaDao.findFilms().map { + FilmData(it.id, it.title) + } +} \ No newline at end of file diff --git a/cinema-server/src/main/kotlin/io/github/ermadmi78/kobby/cinema/server/resolver/SubscriptionResolver.kt b/cinema-server/src/main/kotlin/io/github/ermadmi78/kobby/cinema/server/resolver/SubscriptionResolver.kt new file mode 100644 index 0000000..8916752 --- /dev/null +++ b/cinema-server/src/main/kotlin/io/github/ermadmi78/kobby/cinema/server/resolver/SubscriptionResolver.kt @@ -0,0 +1,19 @@ +package io.github.ermadmi78.kobby.cinema.server.resolver + +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.data.FilmData +import io.github.ermadmi78.kobby.cinema.api.kobby.server.model.resolver.SubscriptionResolutionModel +import io.github.ermadmi78.kobby.cinema.server.eventbus.EventBus +import kotlinx.coroutines.flow.Flow +import org.springframework.stereotype.Component + +/** + * Created on 23.02.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +@Component +class SubscriptionResolver( + private val eventBus: EventBus +) : SubscriptionResolutionModel { + override suspend fun filmCreated(): Flow = eventBus.filmCreatedFlow() +} \ No newline at end of file diff --git a/cinema-server/src/test/kotlin/io/github/ermadmi78/kobby/cinema/server/serverless/CinemaServerlessAdapter.kt b/cinema-server/src/test/kotlin/io/github/ermadmi78/kobby/cinema/server/serverless/CinemaServerlessAdapter.kt new file mode 100644 index 0000000..0529e2f --- /dev/null +++ b/cinema-server/src/test/kotlin/io/github/ermadmi78/kobby/cinema/server/serverless/CinemaServerlessAdapter.kt @@ -0,0 +1,144 @@ +package io.github.ermadmi78.kobby.cinema.server.serverless + +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.paramnames.ParameterNamesModule +import graphql.ExecutionInput +import graphql.ExecutionResult +import graphql.GraphQL +import graphql.schema.GraphQLSchema +import io.github.ermadmi78.kobby.cinema.api.kobby.kotlin.CinemaAdapter +import io.github.ermadmi78.kobby.cinema.api.kobby.kotlin.CinemaReceiver +import io.github.ermadmi78.kobby.cinema.api.kobby.kotlin.dto.MutationDto +import io.github.ermadmi78.kobby.cinema.api.kobby.kotlin.dto.QueryDto +import io.github.ermadmi78.kobby.cinema.api.kobby.kotlin.dto.SubscriptionDto +import io.github.ermadmi78.kobby.cinema.api.kobby.kotlin.dto.graphql.* +import kotlinx.coroutines.future.await +import org.reactivestreams.Publisher +import java.util.concurrent.CompletableFuture + +/** + * Created on 09.03.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +class CinemaServerlessAdapter( + schema: GraphQLSchema +) : CinemaAdapter { + private val graphQL = GraphQL.newGraphQL(schema).build() + val mapper = jacksonObjectMapper() + .registerModule(ParameterNamesModule(JsonCreator.Mode.PROPERTIES)) + + override suspend fun executeQuery( + query: String, + variables: Map + ): QueryDto { + val future: CompletableFuture = graphQL.executeAsync( + ExecutionInput.newExecutionInput() + .query(query) + .variables(variables) + ) + + val json: Map = future.await().toSpecification() + val jsonStr: String = mapper.writeValueAsString(json) + + val result = mapper.readValue(jsonStr, CinemaQueryResult::class.java) + + result.errors?.takeIf { it.isNotEmpty() }?.let { + throw CinemaQueryException( + "GraphQL query failed", + CinemaRequest(query, variables), + it, + result.extensions, + result.data + ) + } + return result.data ?: throw CinemaQueryException( + "GraphQL query completes successfully but returns no data", + CinemaRequest(query, variables), + result.errors, + result.extensions, + null + ) + } + + override suspend fun executeMutation( + query: String, + variables: Map + ): MutationDto { + val future: CompletableFuture = graphQL.executeAsync( + ExecutionInput.newExecutionInput() + .query(query) + .variables(variables) + ) + + val json: Map = future.await().toSpecification() + val jsonStr: String = mapper.writeValueAsString(json) + + val result = mapper.readValue(jsonStr, CinemaMutationResult::class.java) + + result.errors?.takeIf { it.isNotEmpty() }?.let { + throw CinemaMutationException( + "GraphQL query failed", + CinemaRequest(query, variables), + it, + result.extensions, + result.data + ) + } + return result.data ?: throw CinemaMutationException( + "GraphQL query completes successfully but returns no data", + CinemaRequest(query, variables), + result.errors, + result.extensions, + null + ) + } + + override suspend fun executeSubscription( + query: String, + variables: Map, + block: suspend CinemaReceiver.() -> Unit + ) { + val subscriptionExecutionResult = graphQL.execute( + ExecutionInput.newExecutionInput() + .query(query) + .variables(variables) + ) + + val publisher: Publisher = subscriptionExecutionResult.getData() + val subscriber = CinemaServerlessSubscriber() + publisher.subscribe(subscriber) + + subscriber.awaitSubscription() + val receiver = CinemaReceiver { + val json: Map = subscriber.awaitNext().toSpecification() + val jsonStr: String = mapper.writeValueAsString(json) + + val result = mapper.readValue(jsonStr, CinemaSubscriptionResult::class.java) + + result.errors?.takeIf { it.isNotEmpty() }?.let { + throw CinemaSubscriptionException( + "GraphQL query failed", + CinemaRequest(query, variables), + it, + result.extensions, + result.data + ) + } + result.data ?: throw CinemaSubscriptionException( + "GraphQL query completes successfully but returns no data", + CinemaRequest(query, variables), + result.errors, + result.extensions, + null + ) + } + + try { + block(receiver) + } finally { + subscriber.awaitCancel() + } + } +} \ No newline at end of file diff --git a/cinema-server/src/test/kotlin/io/github/ermadmi78/kobby/cinema/server/serverless/CinemaServerlessSubscriber.kt b/cinema-server/src/test/kotlin/io/github/ermadmi78/kobby/cinema/server/serverless/CinemaServerlessSubscriber.kt new file mode 100644 index 0000000..cc61ace --- /dev/null +++ b/cinema-server/src/test/kotlin/io/github/ermadmi78/kobby/cinema/server/serverless/CinemaServerlessSubscriber.kt @@ -0,0 +1,60 @@ +package io.github.ermadmi78.kobby.cinema.server.serverless + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.reactivestreams.Subscriber +import org.reactivestreams.Subscription +import java.util.concurrent.atomic.AtomicReference +import kotlin.coroutines.cancellation.CancellationException + +/** + * Created on 09.03.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +class CinemaServerlessSubscriber : Subscriber { + private val mutex = Mutex() + private val subscriptionHolder = CompletableDeferred() + private val valueHolderRef = AtomicReference>(CompletableDeferred()) + + suspend fun awaitSubscription() { + subscriptionHolder.await() + } + + suspend fun awaitNext(): T = mutex.withLock { + val subscription = subscriptionHolder.await() + val valueHolder = valueHolderRef.get() + if (valueHolder.isActive) { + subscription.request(1L) + } + + return@withLock valueHolder.await() + } + + suspend fun awaitCancel() { + val subscription = subscriptionHolder.await() + subscription.cancel() + } + + override fun onSubscribe(subscription: Subscription) { + subscriptionHolder.complete(subscription) + } + + override fun onNext(value: T) { + valueHolderRef.getAndSet(CompletableDeferred()).complete(value) + } + + override fun onError(t: Throwable) { + subscriptionHolder.completeExceptionally(t) + valueHolderRef.getAndSet( + CompletableDeferred().also { + it.completeExceptionally(t) + } + ).completeExceptionally(t) + } + + override fun onComplete() { + onError(CancellationException("Subscription finished")) + } +} \ No newline at end of file diff --git a/cinema-server/src/test/kotlin/io/github/ermadmi78/kobby/cinema/server/serverless/ServerlessTest.kt b/cinema-server/src/test/kotlin/io/github/ermadmi78/kobby/cinema/server/serverless/ServerlessTest.kt new file mode 100644 index 0000000..97bc9e1 --- /dev/null +++ b/cinema-server/src/test/kotlin/io/github/ermadmi78/kobby/cinema/server/serverless/ServerlessTest.kt @@ -0,0 +1,212 @@ +package io.github.ermadmi78.kobby.cinema.server.serverless + +import io.github.ermadmi78.kobby.cinema.api.kobby.kotlin.CinemaContext +import io.github.ermadmi78.kobby.cinema.api.kobby.kotlin.cinemaContextOf +import io.github.ermadmi78.kobby.cinema.api.kobby.server.buildCinemaSchemaUsingProgram +import io.github.ermadmi78.kobby.cinema.api.kobby.server.buildCinemaSchemaUsingSDL +import io.github.ermadmi78.kobby.cinema.server.dao.CinemaDao +import io.github.ermadmi78.kobby.cinema.server.eventbus.EventBus +import io.github.ermadmi78.kobby.cinema.server.resolver.FilmResolver +import io.github.ermadmi78.kobby.cinema.server.resolver.MutationResolver +import io.github.ermadmi78.kobby.cinema.server.resolver.QueryResolver +import io.github.ermadmi78.kobby.cinema.server.resolver.SubscriptionResolver +import io.kotest.core.spec.style.AnnotationSpec +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.springframework.core.io.support.PathMatchingResourcePatternResolver +import java.io.InputStreamReader +import java.io.Reader + +/** + * Created on 09.03.2026 + * + * @author Dmitry Ermakov (ermadmi78@gmail.com) + */ +class ServerlessTest : AnnotationSpec() { + @Test + suspend fun testSchemaProgramBuilder() { + val cinemaDao = CinemaDao() + val eventBus = EventBus() + val queryResolver = QueryResolver(cinemaDao) + val mutationResolver = MutationResolver(cinemaDao, eventBus) + val subscriptionResolver = SubscriptionResolver(eventBus) + val filmResolver = FilmResolver(cinemaDao) + + val schema = buildCinemaSchemaUsingProgram( + queryResolver, + mutationResolver, + subscriptionResolver, + filmResolver + ) + + val context = cinemaContextOf(CinemaServerlessAdapter(schema)) + execute(context, "Program") + } + + @Test + suspend fun testSchemaSDLBuilder() { + val schemas: Sequence = PathMatchingResourcePatternResolver().getResources( + "classpath*:io/github/ermadmi78/kobby/cinema/api/**/*.graphqls" + ).asSequence().map { InputStreamReader(it.inputStream) } + + val cinemaDao = CinemaDao() + val eventBus = EventBus() + val queryResolver = QueryResolver(cinemaDao) + val mutationResolver = MutationResolver(cinemaDao, eventBus) + val subscriptionResolver = SubscriptionResolver(eventBus) + val filmResolver = FilmResolver(cinemaDao) + + val schema = buildCinemaSchemaUsingSDL( + schemas, + queryResolver, + mutationResolver, + subscriptionResolver, + filmResolver + ) + + val context = cinemaContextOf(CinemaServerlessAdapter(schema)) + execute(context, " SDL ") + } + + private suspend fun execute(context: CinemaContext, header: String) { + println() + println() + println("##################################################################") + println("## Start $header example ##") + println("##################################################################") + println() + println("******************************************************************") + println("** Subscribe to new films asynchronously **") + println("******************************************************************") + println() + val subscribed = Job() + val closed = Job() + + CoroutineScope(Dispatchers.Default).launch { + context.subscription { + filmCreated { + id() + title() + } + }.subscribe { + subscribed.complete() + + // We listen to the first 3 films and exit + for (i in 0 until 3) { + val film = receive().filmCreated + println("<< Film created: ${film.title}") + } + + closed.complete() + } + } + + // Wait for the subscription to be established to avoid races + subscribed.join() + + println() + println("******************************************************************") + println("** Select film by id **") + println("******************************************************************") + println() + + val result = context.query { + film(id = 0L) { + id() + title() + actors { + id() + firstName() + lastName() + } + } + } + + result.film?.also { film -> + println(film.title) + film.actors.forEach { actor -> + println(" ${actor.firstName} ${actor.lastName}") + } + } + + println() + println("******************************************************************") + println("** Create first film **") + println("******************************************************************") + println() + + val first = context.mutation { + createFilm("First") { + id() + title() + } + } + + first.createFilm.also { film -> + println(film.title) + } + + println() + println("******************************************************************") + println("** Create second film **") + println("******************************************************************") + println() + + val second = context.mutation { + createFilm("Second") { + id() + title() + } + } + + second.createFilm.also { film -> + println(film.title) + } + + println() + println("******************************************************************") + println("** Create third film **") + println("******************************************************************") + println() + + val third = context.mutation { + createFilm("Third") { + id() + title() + } + } + + third.createFilm.also { film -> + println(film.title) + } + + println() + println("******************************************************************") + println("** Select all films **") + println("******************************************************************") + println() + + val allFilms = context.query { + films { + id() + title() + actors { + id() + firstName() + lastName() + } + } + } + + allFilms.films.forEach { film -> + println(film.title) + film.actors.forEach { actor -> + println(" ${actor.firstName} ${actor.lastName}") + } + } + + closed.join() + } +} \ No newline at end of file