Skip to content

Support for GraphQL Abstract Data Types

dermakov edited this page Oct 16, 2022 · 4 revisions

In the previous article, we got acquainted with the basic concepts of a generated DSL - projections, entities and data transfer objects. Now let's see how Kobby works with abstract GraphQL data types - interfaces and unions.

GraphQL Interface

Let define a GraphQL schema:

type Query {
    shapes: [Shape!]!
}

interface Shape {
    background: String!
}

type Circle implements Shape {
    background: String!
    radius: Int!
}

type Rectangle implements Shape {
    background: String!
    width: Int!
    height: Int!
}

This scheme allows us to build GraphQL queries of the form:

query {
    shapes {
        background
        ... on Circle {
            radius
        }
        ... on Rectangle {
            width
            height
        }
    }
}

Projection

To support the construction of such queries, Kobby generates the following projection graph:

@ExampleDSL
interface QueryProjection {
    fun shapes(__projection: ShapeQualifiedProjection.() -> Unit = {}): Unit
}

@ExampleDSL
interface ShapeProjection {
    fun background(): Unit
}

@ExampleDSL
interface ShapeQualification {
    fun __onCircle(__projection: CircleProjection.() -> Unit): Unit
    fun __onRectangle(__projection: RectangleProjection.() -> Unit): Unit
}

@ExampleDSL
interface ShapeQualifiedProjection : ShapeProjection, ShapeQualification

@ExampleDSL
interface CircleProjection : ShapeProjection {
    override fun background(): Unit
    fun radius(): Unit
}

@ExampleDSL
interface RectangleProjection : ShapeProjection {
    override fun background(): Unit
    fun width(): Unit
    fun height(): Unit
}

As you can see for the Shape interface, besides the projection, Kobby generates two more interfaces: ShapeQualification and ShapeQualifiedProjection.

  • the ShapeProjection interface is responsible for defining the fields of the Shape interface in a query, and is also the basic interface for projections of inherited types.
  • the ShapeQualification interface is responsible for defining fields of inherited types in a query.
  • the ShapeQualifiedProjection is just "projection" + "qualification" interface.

With the help of such additional interfaces for projection, we can build queries for abstract data types:

GraphQL query:

query {
    shapes {
        background
        ... on Circle {
            radius
        }
        ... on Rectangle {
            width
            height
        }
    }
}

Kotlin query:

fun main() = runBlocking {
    val context: ExampleContext = exampleContextOf(createMyAdapter())
    val response: Query = context.query {
        shapes {
            background()
            __onCircle {
                radius()
            }
            __onRectangle {
                width()
                height()
            }
        }
    }
}

Entity

In the entity graph, the entity Shape is the base interface for entities of inherited types:

interface Query {
    fun __context(): ExampleContext

    val shapes: List<Shape>
}

interface Shape {
    fun __context(): ExampleContext

    val background: String
}

interface Circle : Shape {
    override fun __context(): ExampleContext

    override val background: String
    val radius: Int
}

interface Rectangle : Shape {
    override fun __context(): ExampleContext

    override val background: String
    val width: Int
    val height: Int
}

This entity hierarchy allows us to intuitively handle the results of queries to abstract data types:

 val response: Query = context.query {
    shapes {
        background()
        __onCircle {
            radius()
        }
        __onRectangle {
            width()
            height()
        }
    }
}
response.shapes.forEach { shape: Shape ->
    when (shape) {
        is Circle ->
            println("${shape.background} circle with radius ${shape.radius}")
        is Rectangle ->
            println(
                "${shape.background} rectangle " +
                        "with width ${shape.width} and height ${shape.height}"
            )
    }
}

DTO

The client-side DSL generated by Kobby automatically adds the __typename pseudo-field to queries generated for abstract data types. And in the generated DTO graph, Kobby uses the __typename property to declare the type hierarchy in Jackson's annotations. What helps the adapter to deserialize abstract data types.

@JsonTypeName(value = "Query")
@JsonTypeInfo(
    use = JsonTypeInfo.Id.NAME,
    include = JsonTypeInfo.As.PROPERTY,
    property = "__typename",
    defaultImpl = QueryDto::class
)
@JsonInclude(value = JsonInclude.Include.NON_ABSENT)
data class QueryDto @JsonCreator constructor(
    val shapes: List<ShapeDto>? = null
)

// -----------------------------------------------------------------

@JsonTypeInfo(
    use = JsonTypeInfo.Id.NAME,
    include = JsonTypeInfo.As.PROPERTY,
    property = "__typename"
)
@JsonSubTypes(
    JsonSubTypes.Type(value = CircleDto::class, name = "Circle"),
    JsonSubTypes.Type(value = RectangleDto::class, name = "Rectangle")
)
interface ShapeDto {
    val background: String?
}

// -----------------------------------------------------------------

@JsonTypeName(value = "Circle")
@JsonTypeInfo(
    use = JsonTypeInfo.Id.NAME,
    include = JsonTypeInfo.As.PROPERTY,
    property = "__typename",
    defaultImpl = CircleDto::class
)
@JsonInclude(value = JsonInclude.Include.NON_ABSENT)
data class CircleDto(
    override val background: String? = null,
    val radius: Int? = null
) : ShapeDto

// -----------------------------------------------------------------

@JsonTypeName(value = "Rectangle")
@JsonTypeInfo(
    use = JsonTypeInfo.Id.NAME,
    include = JsonTypeInfo.As.PROPERTY,
    property = "__typename",
    defaultImpl = RectangleDto::class
)
@JsonInclude(value = JsonInclude.Include.NON_ABSENT)
data class RectangleDto(
    override val background: String? = null,
    val width: Int? = null,
    val height: Int? = null
) : ShapeDto

GraphQL Union

Let's make a GraphQL union out of the Shape interface in our example schema, and see how Kobby works with GraphQL unions:

type Query {
    shapes: [Shape!]!
}

union Shape = Circle | Rectangle

type Circle {
    radius: Int!
}

type Rectangle {
    width: Int!
    height: Int!
}

The generated DSL for the new example is identical to the DSL for the old example, but without the background field in the generated Shape interfaces and classes. So the GraphQL union for Kobby is just a marker interface.

Projection

@ExampleDSL
interface QueryProjection {
    fun shapes(__projection: ShapeQualifiedProjection.() -> Unit = {}): Unit
}

@ExampleDSL
interface ShapeProjection

@ExampleDSL
interface ShapeQualification {
    fun __onCircle(__projection: CircleProjection.() -> Unit): Unit
    fun __onRectangle(__projection: RectangleProjection.() -> Unit): Unit
}

@ExampleDSL
interface ShapeQualifiedProjection : ShapeProjection, ShapeQualification

@ExampleDSL
interface CircleProjection : ShapeProjection {
    fun radius(): Unit
}

@ExampleDSL
interface RectangleProjection : ShapeProjection {
    fun width(): Unit
    fun height(): Unit
}

GraphQL query:

query {
    shapes {
        ... on Circle {
            radius
        }
        ... on Rectangle {
            width
            height
        }
    }
}

Kotlin query:

fun main() = runBlocking {
    val context: ExampleContext = exampleContextOf(createMyAdapter())
    val response: Query = context.query {
        shapes {
            __onCircle {
                radius()
            }
            __onRectangle {
                width()
                height()
            }
        }
    }
}

Entity

interface Query {
    fun __context(): ExampleContext

    val shapes: List<Shape>
}

interface Shape {
    fun __context(): ExampleContext
}

interface Circle : Shape {
    override fun __context(): ExampleContext

    val radius: Int
}

interface Rectangle : Shape {
    override fun __context(): ExampleContext

    val width: Int
    val height: Int
}

Handle the results of queries to union:

val response: Query = context.query {
    shapes {
        __onCircle {
            radius()
        }
        __onRectangle {
            width()
            height()
        }
    }
}
response.shapes.forEach { shape: Shape ->
    when (shape) {
        is Circle ->
            println("Circle with radius ${shape.radius}")
        is Rectangle ->
            println(
                "Rectangle with width ${shape.width} and height ${shape.height}"
            )
    }
}

DTO

@JsonTypeName(value = "Query")
@JsonTypeInfo(
    use = JsonTypeInfo.Id.NAME,
    include = JsonTypeInfo.As.PROPERTY,
    property = "__typename",
    defaultImpl = QueryDto::class
)
@JsonInclude(value = JsonInclude.Include.NON_ABSENT)
data class QueryDto @JsonCreator constructor(
    val shapes: List<ShapeDto>? = null
)

// -----------------------------------------------------------------

@JsonTypeInfo(
    use = JsonTypeInfo.Id.NAME,
    include = JsonTypeInfo.As.PROPERTY,
    property = "__typename"
)
@JsonSubTypes(
    JsonSubTypes.Type(value = CircleDto::class, name = "Circle"),
    JsonSubTypes.Type(value = RectangleDto::class, name = "Rectangle")
)
interface ShapeDto

// -----------------------------------------------------------------

@JsonTypeName(value = "Circle")
@JsonTypeInfo(
    use = JsonTypeInfo.Id.NAME,
    include = JsonTypeInfo.As.PROPERTY,
    property = "__typename",
    defaultImpl = CircleDto::class
)
@JsonInclude(value = JsonInclude.Include.NON_ABSENT)
data class CircleDto @JsonCreator constructor(
    val radius: Int? = null
) : ShapeDto

// -----------------------------------------------------------------

@JsonTypeName(value = "Rectangle")
@JsonTypeInfo(
    use = JsonTypeInfo.Id.NAME,
    include = JsonTypeInfo.As.PROPERTY,
    property = "__typename",
    defaultImpl = RectangleDto::class
)
@JsonInclude(value = JsonInclude.Include.NON_ABSENT)
data class RectangleDto(
    val width: Int? = null,
    val height: Int? = null
) : ShapeDto