Git Product home page Git Product logo

ermadmi78 / kobby Goto Github PK

View Code? Open in Web Editor NEW
76.0 7.0 4.0 2.41 MB

Kobby is a codegen plugin of Kotlin DSL Client by GraphQL schema. The generated DSL supports execution of complex GraphQL queries, mutation and subscriptions in Kotlin with syntax similar to native GraphQL syntax.

License: Apache License 2.0

Kotlin 100.00%
kotlin graphql client code-generation kotlin-dsl gradle-plugin maven-plugin graphql-client graphql-schema graphql-subscriptions

kobby's People

Contributors

ermadmi78 avatar lewisenator avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar

kobby's Issues

Compilation error 'More than one overridden descriptor declares a default value'

If we try to generate DSL by schema:

interface Base1 {
    noOverride1(size: Int): String!
    multiOverride(size: Int): String!
}

interface Base2 {
    noOverride2(size: Int): String!
    multiOverride(size: Int): String!
}

type MultiOverride implements Base1 & Base2 {
    noOverride1(size: Int): String!
    noOverride2(size: Int): String!
    multiOverride(size: Int): String!
}

We will receive compilation error:

> Task :compileKotlin FAILED
e: MultiOverride.kt: (49, 37): More than one overridden descriptor declares a default value for 'value-parameter size: Int? = ... defined in MultiOverrideProjection.multiOverride'. As the compiler can not make sure these values agree, this is not allowed.

Build plugin options to configure the Jackson annotations on the generated DTO classes.

There is currently only one build option for generating Jackson annotations. Let expand Gradle and Maven options to provide more abilities to configure Jackson annotations.

Gradle:

kobby {
    kotlin {
        dto {
            jackson {
                // Is Jackson annotations generation enabled
                // By default `true` if `com.fasterxml.jackson.core:jackson-annotations`
                // artifact is in the project dependencies
                enabled = true
            }
        }
    }
}

The Gradle jackson section is defined in KobbyKotlinDtoJacksonExtension class. See source code here

Maven:

    <build>
        <plugins>
            <plugin>
                <groupId>io.github.ermadmi78</groupId>
                <artifactId>kobby-maven-plugin</artifactId>
                <version>1.1.0</version>
                <executions>
                    <execution>
                        <goals>
                            <goal>generate-kotlin</goal>
                        </goals>
                        <configuration>
                            <kotlin>
                                <dto>
                                    <jackson>
                                        <!-- Is Jackson annotations generation enabled -->
                                        <!-- By default `true` if `com.fasterxml.jackson.core:jackson-annotations` -->
                                        <!-- artifact is in the project dependencies -->
                                        <enabled>true</enabled>
                                    </jackson>
                                </dto>
                            </kotlin>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

The Maven jackson section is defined in KotlinDtoJacksonConfig class. See source code here

Non-informative error text when type does not contain inherit fields

For an invalid GraphQL schema:

interface Bug {
    bug: Int
}

type Country implements Bug {
    id: ID!
    name: String!
}

Kobby throws NPE during build. Instead we have to throw an error with message "The object type 'Country' does not have a field 'bug' required via interface 'Bug'"

Generate a builder function for DTO classes with an explicit builder argument

Currently, the builder function for the DTO class only receives the builder in the lambda argument.
For example, for GraphQL type Actor:

type Actor {
    id: ID!
    firstName: String!
    lastName: String
}

Kobby generates a DTO that looks like this:

public data class ActorDto(
  public val id: Long? = null,
  public val firstName: String? = null,
  public val lastName: String? = null,
)

public fun ActorDto(block: ActorDtoBuilder.() -> Unit): ActorDto {
  // ActorDto builder DSL
  return ActorDtoBuilder().apply(block).let {
    ActorDto(
          it.id,
          it.firstName,
          it.lastName
        )
  }
}

public fun ActorDto.copy(block: ActorDtoBuilder.() -> Unit): ActorDto {
  // ActorDto copy DSL
  return ActorDtoBuilder().also {
    it.id = this.id
    it.firstName = this.firstName
    it.lastName = this.lastName
  }
  .apply(block).let {
    ActorDto(
          it.id,
          it.firstName,
          it.lastName
        )
  }
}

public class ActorDtoBuilder {
  public var id: Long? = null

  public var firstName: String? = null

  public var lastName: String? = null
}

As you can see, the ActorDto build function receives a lambda with an ActorDtoBuilder receiver as an argument:

public fun ActorDto(block: ActorDtoBuilder.() -> Unit): ActorDto {
    // skipped...
}

But, there are situations when it is more convenient for us to fill the builder first, and then pass it directly to the builder function. Now, unfortunately, this is not possible. Let's change the generated code like this:

public data class ActorDto(
  public val id: Long? = null,
  public val firstName: String? = null,
  public val lastName: String? = null,
)

// Builder function with an explicit builder argument!!!
public fun ActorDto(builder: ActorDtoBuilder): ActorDto = ActorDto(
  builder.id,
  builder.firstName,
  builder.lastName
)

public fun ActorDto(block: ActorDtoBuilder.() -> Unit): ActorDto {
  // ActorDto builder DSL
  return ActorDtoBuilder().apply(block).let { ActorDto(it) }
}

public fun ActorDto.copy(block: ActorDtoBuilder.() -> Unit): ActorDto {
  // ActorDto copy DSL
  return ActorDtoBuilder().also {
    it.id = this.id
    it.firstName = this.firstName
    it.lastName = this.lastName
  }
  .apply(block).let { ActorDto(it) }
}

public class ActorDtoBuilder {
  public var id: Long? = null

  public var firstName: String? = null

  public var lastName: String? = null
}

Switch on dynamic HTTP headers in Ktor adapters by default

In #26 issue it became possible to switch on dynamic HTTP headers in Ktor adapters. But by default this feature is disabled. It would be great to enable dynamic HTTP headers by default in Kobby 2.0 release.

Attention! It is breaking change!!! To return to previous settings, use:

Gradle:

plugins {
    kotlin("jvm")
    id("io.github.ermadmi78.kobby")
}

kobby {
    kotlin {
        adapter {
            ktor {
                dynamicHttpHeaders = false
            }
        }
    }
}

Maven:

<build>
    <plugins>
        <plugin>
            <groupId>io.github.ermadmi78</groupId>
            <artifactId>kobby-maven-plugin</artifactId>
            <version>1.6.0</version>
            <executions>
                <execution>
                    <phase>generate-sources</phase>
                    <goals>
                        <goal>generate-kotlin</goal>
                    </goals>
                    <configuration>
                        <kotlin>
                            <adapter>
                                <ktor>
                                    <dynamicHttpHeaders>false</dynamicHttpHeaders>
                                </ktor>
                            </adapter>
                        </kotlin>
                    </configuration>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

Ktor 2 support

Hi, are there any plans to support Ktor 2 (which is now at version 2.1.0)?

Ktor adapter generation support

Kobby Gradle plugin should automatically switch on Ktor adapter generation when detect Ktor Client dependency in the project.

Kobby plugin crashes on schemas with comments containing '%' symbols

type Query {
    countries(offset: Int! = 0, limit: Int! = 100): [Country!]!
}

type Country {
    id: ID!
    """
    test %
    """
    name: String!

    films(offset: Int! = 0, limit: Int! = 100): [Film!]!
}

type Film {
    id: ID!
    title: String!

    country: Country!
}

Such a simple scheme gives an error:

Caused by: java.lang.IllegalArgumentException: dangling format characters in 'test %'
	at com.squareup.kotlinpoet.CodeBlock$Builder.add(CodeBlock.kt:284)
	at com.squareup.kotlinpoet.PropertySpec$Builder.addKdoc(PropertySpec.kt:190)
	at io.github.ermadmi78.kobby.generator.kotlin.DtoKt$generateDto$1$1$1$1$3$1$1$1.invoke(dto.kt:42)
	at io.github.ermadmi78.kobby.generator.kotlin.DtoKt$generateDto$1$1$1$1$3$1$1$1.invoke(dto.kt:41)
	at io.github.ermadmi78.kobby.model.KobbyField.comments(KobbyField.kt:236)

Remove the ability to switch off dynamic HTTP headers in Ktor adapters

Attention! It is breaking change!!!
I think the ability to switch off dynamic HTTP headers in Ktor adapters is over-engineering. See #26 and #30. This feature will be removed in version 3.0

plugins {
    kotlin("jvm")
    id("io.github.ermadmi78.kobby")
}

kobby {
    kotlin {
        adapter {
            ktor {
                // Will not be available since version 3.0!
                dynamicHttpHeaders = false
            }
        }
    }
}

Remove the context inheritance feature

Attention! It is breaking change!!!
I think the context inheritance feature is over-engineering. See #20 and #29 for more details. This feature will be removed in version 3.0

plugins {
    id("io.github.ermadmi78.kobby")
}

kobby {
    kotlin {
        entity {
            // Will not be available since version 3.0!
            contextInheritanceEnabled = true
        }
    }
}

"Variable 'counter' is never used" warning

For the schema:

type Query {
    countries(offset: Int! = 0, limit: Int! = 100): [Country!]!
}

type Country {
    id: ID!
    name: String!

    films(offset: Int! = 0, limit: Int! = 100): [Film!]!
}

type Film {
    id: ID!
    title: String!

    country: Country!
}

Kobby generates toString implementation for MutationImpl and SubscriptionImpl classes that produces "Variable 'counter' is never used" warning during compilation of generated DSL:

  @Suppress("UNUSED_PARAMETER", "UNUSED_CHANGED_VALUE")
  public override fun toString(): String = buildString {
    append("Mutation").append('(')

    var counter = 0

    append(')')
  }
  @Suppress("UNUSED_PARAMETER", "UNUSED_CHANGED_VALUE")
  public override fun toString(): String = buildString {
    append("Subscription").append('(')

    var counter = 0

    append(')')
  }

Generate GraphQL query builders by schema

Motivation: to separate the interface for query/mutation generation from execution.

We can generate builder functions by GraphQL schema to just build GraphQL queries without execution. See a working example here (the branch "builder" of "Kobby Gradle Example" project). Just execute "main" function to see example output:

fun buildCinemaQuery(__projection: QueryProjection.() -> Unit): Pair<String, Map<String, Any>> {
    val projectionRef = QueryProjectionImpl().apply(__projection)

    val header = StringBuilder()
    val body = StringBuilder(64)
    val arguments: MutableMap<String, Any> = mutableMapOf()
    projectionRef.___innerBuild(setOf(), header, body, arguments)

    val query = buildString(header.length + body.length + 7) {
        append("query")
        if (header.isNotEmpty()) {
            append('(').append(header).append(')')
        }
        append(body)
    }

    return query to arguments
}

fun buildCinemaMutation(__projection: MutationProjection.() -> Unit): Pair<String, Map<String, Any>> {
    val projectionRef = MutationProjectionImpl().apply(__projection)

    val header = StringBuilder()
    val body = StringBuilder(64)
    val arguments: MutableMap<String, Any> = mutableMapOf()
    projectionRef.___innerBuild(setOf(), header, body, arguments)

    val mutation = buildString(header.length + body.length + 10) {
        append("mutation")
        if (header.isNotEmpty()) {
            append('(').append(header).append(')')
        }
        append(body)
    }

    return mutation to arguments
}

fun buildCinemaSubscription(__projection: SubscriptionProjection.() -> Unit): Pair<String, Map<String, Any>> {
    val projectionRef = SubscriptionProjectionImpl().apply(__projection)

    val header = StringBuilder()
    val body = StringBuilder(64)
    val arguments: MutableMap<String, Any> = mutableMapOf()
    projectionRef.___innerBuild(setOf(), header, body, arguments)

    val subscription = buildString(header.length + body.length + 14) {
        append("subscription")
        if (header.isNotEmpty()) {
            append('(').append(header).append(')')
        }
        append(body)
    }

    return subscription to arguments
}

fun main() {
    val (query, queryArgs) = buildCinemaQuery {
        films {
            genre = Genre.COMEDY
            genre()
            actors {
                limit = -1
                gender()
            }
        }
    }

    println("Query: $query")
    println("Query arguments: $queryArgs")

    val (mutation, mutationArgs) = buildCinemaMutation {
        createCountry("My Country")
    }

    println("Mutation: $mutation")
    println("Mutation arguments: $mutationArgs")

    val (subscription, subscriptionArgs) = buildCinemaSubscription {
        filmCreated(countryId = 7L) {
            genre()
        }
    }
    println("Subscription: $subscription")
    println("Subscription arguments: $subscriptionArgs")
}

Dynamic HTTP headers support

HTTP headers are passed to the default adapter constructor and are used unchanged for each request. But there are situations when HTTP headers need to be generated before each request. For example, to refresh JWT token:

val context = cinemaContextOf(
    CinemaCompositeKtorAdapter(
        client,
        "http://localhost:8080/graphql",
        "ws://localhost:8080/subscriptions",
        object : CinemaMapper {
            override fun serialize(value: Any): String =
                mapper.writeValueAsString(value)

            override fun <T : Any> deserialize(content: String, contentType: KClass<T>): T =
                mapper.readValue(content, contentType.java)
        },
        mapOf("Authorization" to "Basic YWRtaW46YWRtaW4=") //todo How to refresh JWT token here?
    )
)

Let use suspendable lambda to generate HTTP headers before each request:

val context = cinemaContextOf(
    CinemaCompositeKtorAdapter(
        client,
        "http://localhost:8080/graphql",
        "ws://localhost:8080/subscriptions",
        object : CinemaMapper {
            override fun serialize(value: Any): String =
                mapper.writeValueAsString(value)

            override fun <T : Any> deserialize(content: String, contentType: KClass<T>): T =
                mapper.readValue(content, contentType.java)
        },
        { mapOf("Authorization" to "Basic YWRtaW46YWRtaW4=") } //todo Let use suspendable lambda here!
    )
)

Support Kotlin Serialization to enable multiplatform DSL Client

Kobby supports generation of Jackson annotations for DTO classes to provide serialization / deserialization feature. But Jackson does not support Kotlin multiplatform serialization / deserialization. It makes impossible to use Kobby as multiplatform client. We have to support of Kotlinx Serialization for generated DSL client to use Kobby in multiplatform projects.

Examples

Gradle example
Maven example

Requirements

  • Gradle at least version 8.0 is required.
  • Maven at least version 3.9.1 is required.
  • Kotlin at least version 1.8.0 is required.
  • Kotlinx Serialization at least 1.5.0 is required.
  • Ktor at least version 2.0.0 is required for default adapters.

Implicit setup

To enable Kotlinx Serialization support just add org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0 dependency and configure serialization plugin.

Gradle

plugins {
    kotlin("jvm") version "1.8.20"
    kotlin("plugin.serialization") version "1.8.20"
    id("io.github.ermadmi78.kobby") version "3.0.0-beta.01"
}

dependencies {
    // Add this dependency to enable Kotlinx Serialization
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0")
}

Maven

<build>
    <plugins>
        <plugin>
            <groupId>org.jetbrains.kotlin</groupId>
            <artifactId>kotlin-maven-plugin</artifactId>
            <version>1.8.20</version>

            <dependencies>
                <dependency>
                    <groupId>org.jetbrains.kotlin</groupId>
                    <artifactId>kotlin-maven-serialization</artifactId>
                    <version>1.8.20</version>
                </dependency>
            </dependencies>

            <configuration>
                <compilerPlugins>
                    <plugin>kotlinx-serialization</plugin>
                </compilerPlugins>
            </configuration>

            <executions>
                <execution>
                    <id>compile</id>
                    <phase>compile</phase>
                    <goals>
                        <goal>compile</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
        <plugin>
            <groupId>io.github.ermadmi78</groupId>
            <artifactId>kobby-maven-plugin</artifactId>
            <version>3.0.0-beta.01</version>
            <executions>
                <execution>
                    <phase>generate-sources</phase>
                    <goals>
                        <goal>generate-kotlin</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
    </plugins>

    <dependency>
        <groupId>org.jetbrains.kotlinx</groupId>
        <artifactId>kotlinx-serialization-json</artifactId>
        <version>1.5.0</version>
    </dependency>
</build>

Explicit setup

You can explicitly enable (or disable) Kotlinx Serialization support in the generated code, but you still need to add kotlinx-serialization-json dependency and configure serialization plugin. In addition to the "implicit setup" you can add:

Gradle

kobby {
    kotlin {
        dto {
            serialization {
                // Is Kotlinx Serialization enabled.
                // By default, "true" if "org.jetbrains.kotlinx:kotlinx-serialization-json" artifact
                // is in the project dependencies.
                enabled = true

                // Name of the class descriptor property for polymorphic serialization.
                classDiscriminator = "__typename"

                // Specifies whether encounters of unknown properties in the input JSON
                // should be ignored instead of throwing SerializationException.
                ignoreUnknownKeys = true

                // Specifies whether default values of Kotlin properties should be encoded to JSON.
                encodeDefaults = false
                
                // Specifies whether resulting JSON should be pretty-printed.
                prettyPrint = false
            }
        }
    }
}

Maven

<plugin>
    <groupId>io.github.ermadmi78</groupId>
    <artifactId>kobby-maven-plugin</artifactId>
    <version>3.0.0-beta.01</version>
    <executions>
        <execution>
            <phase>generate-sources</phase>
            <goals>
                <goal>generate-kotlin</goal>
            </goals>
            <configuration>
                <kotlin>
                    <dto>
                        <serialization>
                            <!-- Is Kotlinx Serialization enabled. -->
                            <!-- By default, "true" if "org.jetbrains.kotlinx:kotlinx-serialization-json" -->
                            <!-- artifact is in the project dependencies. -->
                            <enabled>true</enabled>

                            <!-- Name of the class descriptor property for polymorphic serialization. -->
                            <classDiscriminator>__typename</classDiscriminator>

                            <!-- Specifies whether encounters of unknown properties in the input JSON -->
                            <!-- should be ignored instead of throwing SerializationException. -->
                            <ignoreUnknownKeys>true</ignoreUnknownKeys>

                            <!-- Specifies whether default values of Kotlin properties -->
                            <!-- should be encoded to JSON. -->
                            <encodeDefaults>false</encodeDefaults>

                            <!-- Specifies whether resulting JSON should be pretty-printed. -->
                            <prettyPrint>false</prettyPrint>
                        </serialization>
                    </dto>
                </kotlin>
            </configuration>
        </execution>
    </executions>
</plugin>

Kotlinx Serialization entry point

The Kotlinx Serialization entry point in the generated DSL is placed near the DSL context entry point (root file xxx.kt, where xxx is the name of the context). For example, for a context named cinema it would look like this:

cinema.kt

/**
 * Default entry point to work with JSON serialization.
 */
public val cinemaJson: Json = Json {
  classDiscriminator = "__typename"
  ignoreUnknownKeys = true
  encodeDefaults = false
  prettyPrint = false
  serializersModule = SerializersModule {
    polymorphic(EntityDto::class) {
      subclass(FilmDto::class)
      subclass(ActorDto::class)
      subclass(CountryDto::class)
    }
    polymorphic(TaggableDto::class) {
      subclass(FilmDto::class)
      subclass(ActorDto::class)
    }
    polymorphic(NativeDto::class) {
      subclass(FilmDto::class)
      subclass(ActorDto::class)
    }
  }
}


public fun cinemaContextOf(adapter: CinemaAdapter): CinemaContext = CinemaContextImpl(adapter)

You must pass cinemaJson to the default adapters to ensure Kotlinx Serialization.

Simple Adapter configuration

val client = HttpClient(CIO) {
    install(ContentNegotiation) {
        json(cinemaJson)
    }
}

val context = cinemaContextOf(
    CinemaSimpleKtorAdapter(client, "http://localhost:8080/graphql")
)

Composite Adapter configuration

val client = HttpClient(CIO) {
    install(WebSockets)
}

val context = cinemaContextOf(
    CinemaCompositeKtorAdapter(
        client,
        "http://localhost:8080/graphql",
        "ws://localhost:8080/subscriptions"
    )
)

You don't need to pass cinemaJson to the composite adapter as it is configured as the default value of mapper argument:

public open class CinemaCompositeKtorAdapter(
  protected val client: HttpClient,
  protected val httpUrl: String,
  protected val webSocketUrl: String,
  protected val mapper: Json = cinemaJson, // cinemaJson is configured by default!
  protected val requestHeaders: suspend () -> Map<String, String> = { mapOf<String, String>() },
  protected val subscriptionPayload: suspend () -> JsonObject? = { null },
  protected val subscriptionReceiveTimeoutMillis: Long? = null,
  protected val httpAuthorizationTokenHeader: String = "Authorization",
  protected val webSocketAuthorizationTokenHeader: String = "authToken",
  protected val idGenerator: () -> String = { Random.nextLong().toString() },
  protected val listener: (CinemaRequest) -> Unit = {},
) : CinemaAdapter {
  // Skipped
}

Custom serializers

You can configure a custom serializer to any type associated with a scalar. For example, let's define a Date scalar in our schema and associate it with a java.time.LocalDate.

scalar Date

type Query {
    extract: Date!
}

First, we must write custom serializer for java.time.LocalDate:

object LocalDateSerializer : KSerializer<LocalDate> {
    override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LocalDate", PrimitiveKind.STRING)

    override fun serialize(encoder: Encoder, value: LocalDate) =
        encoder.encodeString(value.toString())

    override fun deserialize(decoder: Decoder): LocalDate =
        LocalDate.parse(decoder.decodeString())
}

Second, we must bind java.time.LocalDate to Date scalar and set up LocalDateSerializer for it:

Gradle (see scalar mapping)

kobby {
    kotlin {
        scalars = mapOf(
            "Date" to typeOf("java.time", "LocalDate")
                .serializer(
                    "io.github.ermadmi78.kobby.cinema.api.kobby.kotlin.dto", // package name
                    "LocalDateSerializer" // class name
                )
        )
    }
}

Maven (see scalar mapping)

<plugin>
    <groupId>io.github.ermadmi78</groupId>
    <artifactId>kobby-maven-plugin</artifactId>
    <version>3.0.0-beta.01</version>
    <executions>
        <execution>
            <phase>generate-sources</phase>
            <goals>
                <goal>generate-kotlin</goal>
            </goals>
            <configuration>
                <kotlin>
                    <scalars>
                        <Date>
                            <packageName>java.time</packageName>
                            <className>LocalDate</className>
                            <serializer>
                                <packageName>
                                    io.github.ermadmi78.kobby.cinema.api.kobby.kotlin.dto
                                </packageName>
                                <className>LocalDateSerializer</className>
                            </serializer>
                        </Date>
                    </scalars>
                </kotlin>
            </configuration>
        </execution>
    </executions>
</plugin>

Mixing serialization engines

During the development process, it turned out that the Kotlinx Serialization engine does not like type Any at all.
To get around this limitation, the plugin replaces type Any in the generated code according to the following rules:

For example, the GraphQL request DTO for Jackson serialization engine looks like this:

public data class CinemaRequest(
  public val query: String,

  @JsonInclude(value = JsonInclude.Include.NON_EMPTY)
  public val variables: Map<String, Any?>? = null,

  @JsonInclude(value = JsonInclude.Include.NON_ABSENT)
  public val operationName: String? = null,
)

But the same DTO for Kotlinx Serialization engine looks like this:

@Serializable
public data class CinemaRequest(
  public val query: String,
  public val variables: JsonObject? = null,
  public val operationName: String? = null,
)

Such a replacement leads to the fact that it is impossible to generate DTO classes that can be serialized using Jackson and Kotlinx Serialization at the same time. Therefore, you will need to choose one of the serialization engines and use only that.

Generate extension functions toBuilder, toDto, and toInput for DTO classes

The DTO and Input classes generated by Kobby contain helper builder classes that make it easy to create instances of these classes manually. The purpose of this issue is to extend the use of builder classes.

DTO classes refactoring

For GraphQL type Actor:

type Actor {
    id: ID!
    firstName: String!
    lastName: String
}

Kobby generates a DTO class that looks like this:

public data class ActorDto(
  public val id: Long? = null,
  public val firstName: String? = null,
  public val lastName: String? = null,
)

public fun ActorDto(block: ActorDtoBuilder.() -> Unit): ActorDto {
  // ActorDto builder DSL
  return ActorDtoBuilder().apply(block).let {
    ActorDto(
          it.id,
          it.firstName,
          it.lastName
        )
  }
}

public fun ActorDto.copy(block: ActorDtoBuilder.() -> Unit): ActorDto {
  // ActorDto copy DSL
  return ActorDtoBuilder().also {
    it.id = this.id
    it.firstName = this.firstName
    it.lastName = this.lastName
  }
  .apply(block).let {
    ActorDto(
          it.id,
          it.firstName,
          it.lastName
        )
  }
}

public class ActorDtoBuilder {
  public var id: Long? = null

  public var firstName: String? = null

  public var lastName: String? = null
}

Let's add the toBuilder and toDto extension functions to the generated DTO class, which will make the builder easier to use:

public data class ActorDto(
  public val id: Long? = null,
  public val firstName: String? = null,
  public val lastName: String? = null,
)

public fun ActorDto.toBuilder(): ActorDtoBuilder = ActorDtoBuilder().also {
  it.id = this.id
  it.firstName = this.firstName
  it.lastName = this.lastName
}

public fun ActorDtoBuilder.toDto(): ActorDto = ActorDto(
  id,
  firstName,
  lastName
)

public fun ActorDto(block: ActorDtoBuilder.() -> Unit): ActorDto =
    ActorDtoBuilder().apply(block).toDto()

public fun ActorDto.copy(block: ActorDtoBuilder.() -> Unit): ActorDto =
    toBuilder().apply(block).toDto()

public class ActorDtoBuilder {
  public var id: Long? = null

  public var firstName: String? = null

  public var lastName: String? = null
}

Input classes refactoring

For GraphQL input ActorInput:

input ActorInput {
    id: ID!
    firstName: String!
    lastName: String
}

Kobby generates an Input class that looks like this:

public data class ActorInput(
  public val id: Long,
  public val firstName: String,
  public val lastName: String? = null,
)

public fun ActorInput(block: ActorInputBuilder.() -> Unit): ActorInput {
  // ActorInput builder DSL
  return ActorInputBuilder().apply(block).let {
    ActorInput(
          it.id ?: error("ActorInput: 'id' must not be null"),
          it.firstName ?: error("ActorInput: 'firstName' must not be null"),
          it.lastName
        )
  }
}

public fun ActorInput.copy(block: ActorInputBuilder.() -> Unit): ActorInput {
  // ActorInput copy DSL
  return ActorInputBuilder().also {
    it.id = this.id
    it.firstName = this.firstName
    it.lastName = this.lastName
  }
  .apply(block).let {
    ActorInput(
          it.id ?: error("ActorInput: 'id' must not be null"),
          it.firstName ?: error("ActorInput: 'firstName' must not be null"),
          it.lastName
        )
  }
}

public class ActorInputBuilder {
  public var id: Long? = null

  public var firstName: String? = null

  public var lastName: String? = null
}

Let's add the toBuilder and toInput extension functions to the generated Input class, which will make the builder easier to use:

public data class ActorInput(
  public val id: Long,
  public val firstName: String,
  public val lastName: String? = null,
)

public fun ActorInput.toBuilder(): ActorInputBuilder = ActorInputBuilder().also {
  it.id = this.id
  it.firstName = this.firstName
  it.lastName = this.lastName
}

public fun ActorInputBuilder.toInput(): ActorInput = ActorInput(
  id ?: error("ActorInput: 'id' must not be null"),
  firstName ?: error("ActorInput: 'firstName' must not be null"),
  lastName
)

public fun ActorInput(block: ActorInputBuilder.() -> Unit): ActorInput =
    ActorInputBuilder().apply(block).toInput()

public fun ActorInput.copy(block: ActorInputBuilder.() -> Unit): ActorInput =
    toBuilder().apply(block).toInput()

public class ActorInputBuilder {
  public var id: Long? = null

  public var firstName: String? = null

  public var lastName: String? = null
}

Customization

Use the following build script blocks to customize extension function names.

Gradle:

plugins {
    id("io.github.ermadmi78.kobby")
}

kobby {
    kotlin {
        dto {
            builder {
                // Name of DTO based "toBuilder" function for DTO classes
                toBuilderFun = "toBuilder"

                // Name of builder based "toDto" function for DTO classes
                toDtoFun = "toDto"

                // Name of builder based "toInput" function for DTO input classes
                toInputFun = "toInput"
            }
        }
    }
}

Maven:

<build>
    <plugins>
        <plugin>
            <groupId>io.github.ermadmi78</groupId>
            <artifactId>kobby-maven-plugin</artifactId>
            <version>${kobby.version}</version>
            <executions>
                <execution>
                    <phase>generate-sources</phase>
                    <goals>
                        <goal>generate-kotlin</goal>
                    </goals>
                    <configuration>
                        <kotlin>
                            <dto>
                                <builder>
                                    <!-- Name of DTO based "toBuilder" function for DTO classes -->
                                    <toBuilderFun>toBuilder</toBuilderFun>

                                    <!-- Name of builder based "toDto" function for DTO classes -->
                                    <toDtoFun>toDto</toDtoFun>

                                    <!-- Name of builder based "toInput" function for DTO input classes -->
                                    <toInputFun>toInput</toInputFun>
                                </builder>
                            </dto>
                        </kotlin>
                    </configuration>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

Commit function for subscription

A typical subscription looks like this:

context.subscription {
    filmCreated {
        title()
    }
}.subscribe {
    while (true) {
        val message: Subscription = receive()  // receive the next message
        // Do something with message
    }
}

This looks good for a web-socket subscription session. But when we subscribe to a message broker like Apache Kafka or Apache Pulsar, we need to not only receive the message, but also commit it. Let support commit function for a subscription receiver interface:

context.subscription {
    filmCreated {
        title()
    }
}.subscribe {
    while (true) {
        val message: Subscription = receive()  // receive the next message
        // Do something with the received message
        commit() // Commit the successfully handled message
    }
}

The commit function can be implemented in an adapter that uses a message broker to subscribe.


The generation of commit function for a subscription is disabled by default. To enable it use:

Gradle

plugins {
    id("io.github.ermadmi78.kobby")
}

kobby {
    kotlin {
        context {
            commitEnabled = true
        }
    }
}

Maven

    <build>
        <plugins>
            <plugin>
                <groupId>io.github.ermadmi78</groupId>
                <artifactId>kobby-maven-plugin</artifactId>
                <version>2.1.0</version>
                <executions>
                    <execution>
                        <phase>generate-sources</phase>
                        <goals>
                            <goal>generate-kotlin</goal>
                        </goals>
                        <configuration>
                            <kotlin>
                                <context>
                                    <commitEnabled>true</commitEnabled>
                                </context>
                            </kotlin>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

Graphql enums with "name" field

Hi,

first of all, kobby is a life saver, thanks for making it! I would really like to use it as it is very similar to https://github.com/mobxjs/mst-gql, which I use on the frontend.

I just tried each and every graphql kotlin and java client generation library and they are close to unusable with such huge schemas, which are generated by Hasura.

However, one problem I am facing with kobby right now is that in my Hasura generated schema I have an enum like this:

# update columns of table "user"
enum user_update_column {
  # column name
  email

  # column name
  id

  # column name
  name
}
/**
 *  update columns of table "user"
 */
public enum class user_update_column {
  /**
   *  column name
   */
  email,
  /**
   *  column name
   */
  id,
  /**
   *  column name
   */
  name
}

This is invalid and the kotlin compiler fails with

Conflicting declarations: public final val name: String, enum entry name

It seems that in kotlin one cannot have an enum field called name. Not even with backticks.

https://discuss.kotlinlang.org/t/is-name-not-allowed-in-enums/24191/3

Can this somehow be fixed?

Split ___innerBuild function into multiple subfunctions

Motivation:
The ___innerBuild function in the generated entity implementation classes is responsible for building the query string. Due to the peculiarities of the implementation, the function can get very large. For example, in my production schema, the size of the generated function in mutation entity implementation already reaches 3 thousand lines. In this issue, the user has encountered an 18k line function that Kotlin cannot compile. We have to break this function into a number of sub-functions so that we can work with large schemas using the Kobby plugin.

Receive message timeout for subscriptions in Ktor composite adapter

The GraphQL over WebSocket Protocol supports GQL_CONNECTION_KEEP_ALIVE messages that the server must periodically send in order to keep websocket connection alive. The generated Ktor composite adapter just ignores these messages now. But if the server stops sending any messages, then the subscription event listener will simply freeze.
We need to add the ability to set a timeout for receiving subscribed messages in the generated adapter to take full advantage of the GraphQL over WebSocket protocol.

Ability to configure access to the context from the entity interface

The generated entity interface implements the context interface to provide access to GraphQL query building functionality. For example the entity interface Film generated by tutorial schema looks like following:

public interface Film : CinemaContext {
  public val id: Long
  public val title: String
  public val actors: List<Actor>

  public override suspend fun query(__projection: QueryProjection.() -> Unit): Query
  public override suspend fun mutation(__projection: MutationProjection.() -> Unit): Mutation
  public override fun subscription(__projection: SubscriptionProjection.() -> Unit): 
    CinemaSubscriber<Subscription>
}

As you can see, the entity interface Film extends the context interface CinemaContext, which makes the entity an entry point for executing GraphQL queries. We can use Kotlin extension functions to create a smart API that looks like rich domain model.
But the Kotlin extension functions defined in the context interface scope are available in the entity interface scope because the entity interface extends the context interface. Thus, context inheritance often leads to confusion in the customized API. To avoid such problems, we need to abandon context inheritance in the entity interface in favor of an accessor for the context.

Our entity interface might look like this:

public interface Film {
  public val id: Long
  public val title: String
  public val actors: List<Actor>

  public fun __context(): CinemaContext
}

But such a change would break the backward compatibility of the entity interfaces. Let's add the following generation settings to maintain backward compatibility:

plugins {
    kotlin("jvm")
    id("io.github.ermadmi78.kobby")
}

kobby {
    kotlin {
        entity {
            // Inherit context interface in entity interface
            contextInheritanceEnabled = true // true by default

            // Generate context access function
            contextFunEnabled = false // false by default

            // Context access function name
            contextFunName = "__context"
        }
    }
}

Change type of errorType field in Error DTO class from enum to String

To deserialize the error in the GraphQL response, Kobby generates a DTO class that looks like this:

public data class CinemaError(
  public val message: String,
  @JsonInclude(value = JsonInclude.Include.NON_EMPTY)
  public val locations: List<CinemaErrorSourceLocation>? = null,
  @JsonInclude(value = JsonInclude.Include.NON_ABSENT)
  public val errorType: CinemaErrorType? = null,
  @JsonInclude(value = JsonInclude.Include.NON_EMPTY)
  public val path: List<Any>? = null,
  @JsonInclude(value = JsonInclude.Include.NON_EMPTY)
  public val extensions: Map<String, Any?>? = null,
) 

The errorType field is enum:

public enum class CinemaErrorType {
  InvalidSyntax,
  ValidationError,
  DataFetchingException,
  OperationNotSupported,
  ExecutionAborted,
}

This enum limits the possible error type to 5 values. But there are no standards for possible error types in GraphQL responses, which sometimes results in deserialization errors. For example, in this case, the error type is UnauthorizedException.

I think we should stop using enums for error types and return the error type as a String.

Change default entity context access from inheritance to function

In #20 issue it became possible to configure access to the context from the entity interface. The default access strategy is "inheritance" - when each entity implements the context interface. But, as the practice of using the plugin in real projects has shown, such an access strategy is extremely inconvenient. Context inheritance often leads to confusion in the customized API.
It would be great to change the default context access strategy from inheritance to a function in Kobby 2.0 release.

Attention! It is breaking change!!! To return to previous settings, use:

Gradle

plugins {
    id("io.github.ermadmi78.kobby")
}

kobby {
    kotlin {
        entity {
            contextInheritanceEnabled = true
            contextFunEnabled = false
        }
    }
}

Maven

    <build>
        <plugins>
            <plugin>
                <groupId>io.github.ermadmi78</groupId>
                <artifactId>kobby-maven-plugin</artifactId>
                <version>1.4.0</version>
                <executions>
                    <execution>
                        <phase>generate-sources</phase>
                        <goals>
                            <goal>generate-kotlin</goal>
                        </goals>
                        <configuration>
                            <kotlin>
                                <entity>
                                    <contextInheritanceEnabled>true</contextInheritanceEnabled>
                                    <contextFunEnabled>false</contextFunEnabled>
                                </entity>
                            </kotlin>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

Validation error when deserializing nested GraphQL inputs

Let define nested GraphQL inputs:

enum ShapeType {
    POINT,
    LINE,
    POLYGON
}

input PointInput {
    x: Float!
    y: Float!
}

input ShapeInput {
    type: ShapeType!
    points: [PointInput!]!
    holes: [[PointInput!]!]
}

The ShapeInput contains nested PointInput. For such an inputs Kobby generates DTO classes:

public enum class ShapeType {
  POINT,
  LINE,
  POLYGON,
}

@JsonTypeName(value = "PointInput")
@JsonTypeInfo(
  use = JsonTypeInfo.Id.NAME,
  include = JsonTypeInfo.As.PROPERTY,
  property = "__typename",
  defaultImpl = PointInput::class
)
@JsonInclude(value = JsonInclude.Include.NON_ABSENT)
public data class PointInput(
  public val x: Double,
  public val y: Double
)

@JsonTypeName(value = "ShapeInput")
@JsonTypeInfo(
  use = JsonTypeInfo.Id.NAME,
  include = JsonTypeInfo.As.PROPERTY,
  property = "__typename",
  defaultImpl = ShapeInput::class
)
@JsonInclude(value = JsonInclude.Include.NON_ABSENT)
public data class ShapeInput(
  public val type: ShapeType,
  public val points: List<PointInput>,
  public val holes: List<List<PointInput>>? = null
)

When I try to send the ShapeInput with nested PointInput to GraphQL server I receive an validation error:

The variables input contains a field name '__typename' that is not defined for input object type 'PointInput'

To avoid such validation errors Kobby must not generate the JsonTypeName and the JsonTypeInfo annotations for input GraphQL types. For other GraphQL types, the behavior should remain the same.

Task kobbyKotlin failed if graphql files contain Russian symbol "л"

Error message

Can't close a statement that hasn't been opened (closing » is not preceded by an
opening «).
Current code block:
- Format parts: [РЈРЅРёРєР°Р, », ьный идентификатор, \n]
- Arguments: []

Stacktrace

Exception is:
org.gradle.api.tasks.TaskExecutionException: Execution failed for task ':kobbyKotlin'.
	at org.gradle.api.internal.tasks.execution.ExecuteActionsTaskExecuter.lambda$executeIfValid$1(ExecuteActionsTaskExecuter.java:188)
	at org.gradle.internal.Try$Failure.ifSuccessfulOrElse(Try.java:282)
	at org.gradle.api.internal.tasks.execution.ExecuteActionsTaskExecuter.executeIfValid(ExecuteActionsTaskExecuter.java:186)
	at org.gradle.api.internal.tasks.execution.ExecuteActionsTaskExecuter.execute(ExecuteActionsTaskExecuter.java:174)
	at org.gradle.api.internal.tasks.execution.CleanupStaleOutputsExecuter.execute(CleanupStaleOutputsExecuter.java:109)
	at org.gradle.api.internal.tasks.execution.FinalizePropertiesTaskExecuter.execute(FinalizePropertiesTaskExecuter.java:46)
	at org.gradle.api.internal.tasks.execution.ResolveTaskExecutionModeExecuter.execute(ResolveTaskExecutionModeExecuter.java:51)
	at org.gradle.api.internal.tasks.execution.SkipTaskWithNoActionsExecuter.execute(SkipTaskWithNoActionsExecuter.java:57)
	at org.gradle.api.internal.tasks.execution.SkipOnlyIfTaskExecuter.execute(SkipOnlyIfTaskExecuter.java:56)
	at org.gradle.api.internal.tasks.execution.CatchExceptionTaskExecuter.execute(CatchExceptionTaskExecuter.java:36)
	at org.gradle.api.internal.tasks.execution.EventFiringTaskExecuter$1.executeTask(EventFiringTaskExecuter.java:77)
	at org.gradle.api.internal.tasks.execution.EventFiringTaskExecuter$1.call(EventFiringTaskExecuter.java:55)
	at org.gradle.api.internal.tasks.execution.EventFiringTaskExecuter$1.call(EventFiringTaskExecuter.java:52)
	at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:200)
	at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:195)
	at org.gradle.internal.operations.DefaultBuildOperationRunner$3.execute(DefaultBuildOperationRunner.java:75)
	at org.gradle.internal.operations.DefaultBuildOperationRunner$3.execute(DefaultBuildOperationRunner.java:68)
	at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:153)
	at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:68)
	at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:62)
	at org.gradle.internal.operations.DefaultBuildOperationExecutor.lambda$call$2(DefaultBuildOperationExecutor.java:79)
	at org.gradle.internal.operations.UnmanagedBuildOperationWrapper.callWithUnmanagedSupport(UnmanagedBuildOperationWrapper.java:54)
	at org.gradle.internal.operations.DefaultBuildOperationExecutor.call(DefaultBuildOperationExecutor.java:79)
	at org.gradle.api.internal.tasks.execution.EventFiringTaskExecuter.execute(EventFiringTaskExecuter.java:52)
	at org.gradle.execution.plan.LocalTaskNodeExecutor.execute(LocalTaskNodeExecutor.java:74)
	at org.gradle.execution.taskgraph.DefaultTaskExecutionGraph$InvokeNodeExecutorsAction.execute(DefaultTaskExecutionGraph.java:402)
	at org.gradle.execution.taskgraph.DefaultTaskExecutionGraph$InvokeNodeExecutorsAction.execute(DefaultTaskExecutionGraph.java:389)
	at org.gradle.execution.taskgraph.DefaultTaskExecutionGraph$BuildOperationAwareExecutionAction.execute(DefaultTaskExecutionGraph.java:382)
	at org.gradle.execution.taskgraph.DefaultTaskExecutionGraph$BuildOperationAwareExecutionAction.execute(DefaultTaskExecutionGraph.java:368)
	at org.gradle.execution.plan.DefaultPlanExecutor$ExecutorWorker.lambda$run$0(DefaultPlanExecutor.java:127)
	at org.gradle.execution.plan.DefaultPlanExecutor$ExecutorWorker.execute(DefaultPlanExecutor.java:191)
	at org.gradle.execution.plan.DefaultPlanExecutor$ExecutorWorker.executeNextNode(DefaultPlanExecutor.java:182)
	at org.gradle.execution.plan.DefaultPlanExecutor$ExecutorWorker.run(DefaultPlanExecutor.java:124)
	at org.gradle.internal.concurrent.ExecutorPolicy$CatchAndRecordFailures.onExecute(ExecutorPolicy.java:64)
	at org.gradle.internal.concurrent.ManagedExecutorImpl$1.run(ManagedExecutorImpl.java:48)
	at org.gradle.internal.concurrent.ThreadFactoryImpl$ManagedThreadRunnable.run(ThreadFactoryImpl.java:61)
Caused by: java.lang.IllegalStateException: Can't close a statement that hasn't been opened (closing � is not preceded by an
opening �).
Current code block:
- Format parts: [Уника�, �, ьный идентификатор, \n]
- Arguments: []

	at com.squareup.kotlinpoet.CodeWriter.emitCode(CodeWriter.kt:316)
	at com.squareup.kotlinpoet.CodeWriter.emitCode$default(CodeWriter.kt:219)
	at com.squareup.kotlinpoet.CodeWriter.emitKdoc(CodeWriter.kt:137)
	at com.squareup.kotlinpoet.PropertySpec.emit$kotlinpoet(PropertySpec.kt:71)
	at com.squareup.kotlinpoet.PropertySpec.emit$kotlinpoet$default(PropertySpec.kt:59)
	at com.squareup.kotlinpoet.TypeSpec$emit$1$1.invoke(TypeSpec.kt:189)
	at com.squareup.kotlinpoet.TypeSpec$emit$1$1.invoke(TypeSpec.kt:186)
	at com.squareup.kotlinpoet.ParameterSpecKt.emit(ParameterSpec.kt:230)
	at com.squareup.kotlinpoet.TypeSpec.emit$kotlinpoet(TypeSpec.kt:186)
	at com.squareup.kotlinpoet.TypeSpec.emit$kotlinpoet$default(TypeSpec.kt:97)
	at com.squareup.kotlinpoet.FileSpec.emit(FileSpec.kt:167)
	at com.squareup.kotlinpoet.FileSpec.writeTo(FileSpec.kt:65)
	at com.squareup.kotlinpoet.FileSpec.writeTo(FileSpec.kt:95)
	at com.squareup.kotlinpoet.FileSpec.writeTo(FileSpec.kt:100)
	at io.github.ermadmi78.kobby.generator.kotlin._layoutKt$toKotlinFile$1.writeTo(_layout.kt:40)
	at io.github.ermadmi78.kobby.task.KobbyKotlin.generateKotlinDslClientAction(KobbyKotlin.kt:984)

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.