DEV Community

loading...
Cover image for Get productive with GraphQL - Type Adapters

Get productive with GraphQL - Type Adapters

Mahendran
Android developer, Kotlin - KMP - GraphQL enthusiast
Originally published at mahendranv.github.io Updated on ・5 min read

Cross-posting from my blog

Get productive with GraphQL - Type Adapters

One of the demanding features in any serializer-deserializer is the ability to convert data to user defined format. GraphQL is smart enough to generate classes & parsers for your primitive / nested objects. However, there are cases where GQL cannot determine what to do with a field. In such cases, we can lend a hand and get type safe fields in return.

Use case taken for this post is timestamp -- It's string by transmission but we need it as Date object. We'll peek though the schema & query files that drives the codegen and get it to bend for our need.

I covered the code generation part for clarity before the implementation. For the implementation, skip to the how to section.

image


Pre-requisite

Basic understanding of what is GraphQL. Having a working GraphQL android codebase is even better. Read this for setting up the ApolloClient and how to write query for a given schema. Also, Apollo has a quick guide to get started with GraphQL.


Data types in GraphQL

A GraphQL query is made of nodes and leaves (referred as scalars) too often. There are default scalars defined in system as such, Int, Float, String & Boolean. However, like in our case, backend can mark fields as custom scalars to expect client-side processing.

// File: "queries.graphql"

expenses {         ## node
    id
    amount
    remarks
    is_income
    created_at     ## Custom scalar
}
Enter fullscreen mode Exit fullscreen mode

Here in the above query, we have created_at maked with timestamptz. Let's scan through schema.json for how it looks.


Peek to the schema

// Example - default scalar
{
    "args": [],
    "isDeprecated": false,
    "deprecationReason": null,
    "name": "remarks",
    "type": {
        "kind": "NON_NULL",
        "name": null,
        "ofType": {
            "kind": "SCALAR",
            "name": "String",   // Known date type
            "ofType": null
        }
    },
    "description": "Where the money went"
},

// Example - custom scalar
{
    "name": "created_at",
    "type": {
        "kind": "NON_NULL",
        "name": null,
        "ofType": {
            "kind": "SCALAR",
            "name": "timestamptz",
            "ofType": null
        }
    },
    "description": "Created timestamp"
},
Enter fullscreen mode Exit fullscreen mode

As we can see, the timestamp cannot fall into a known scalar platform specific implementation for below reasons

  1. The plugin just don't know what's the date format is. There is no single format universally agreed for DateTime communication. Moreover, it can be just date or time. So, it's up to the dev to decide on this.
  2. Plenty of options out there when it comes to Date and time. Based on personal preference, one can go for Jave Date, Calendar, DateTime, Joda(don't use this), three-ten-bp or kotlinx datetime Api.

How codegen handles custom scalars?

Apollo generates an enum to book-keep the custom scalars. It maps the scalar name to corresponding (fully qualified) type name known in the platform. Without any type adapters, it looks like this.

// File: "CustomTypes.kt"

import com.apollographql.apollo.api.ScalarType
import kotlin.String

enum class CustomType : ScalarType {

  TIMESTAMPTZ {
    override fun typeName(): String = "timestamptz"

    override fun className(): String = "kotlin.Any"
  }
}
Enter fullscreen mode Exit fullscreen mode

Generated class for Expense node looks like this. Currently the created_at is of type Any, waiting for us to make it concrete. Scaning through the the file, you'll find where it is serialized and deserialized.

// File: "Expenses.kt"

data class Expense(
    val __typename: String = "expenses",
    val id: Int,
    val amount: Int,
    val remarks: String,
    val is_income: Boolean,
    val created_at: Any
  ) {
    fun marshaller(): ResponseFieldMarshaller = ResponseFieldMarshaller.invoke { writer ->
            ...
      writer.writeString(RESPONSE_FIELDS[3], this@Expense.remarks)
            ...
      // Serializing cusom fields here                                                                          
      writer.writeCustom(RESPONSE_FIELDS[5] as ResponseField.CustomTypeField,
          this@Expense.created_at)
    }

    companion object {
      private val RESPONSE_FIELDS: Array<ResponseField> = arrayOf(
          ...
          ResponseField.forString("remarks", "remarks", null, false, null),
                    ...

          // Definition of custom response field
          ResponseField.forCustomType("created_at", "created_at", null, false,
              CustomType.TIMESTAMPTZ, null)
          )

      operator fun invoke(reader: ResponseReader): Expense = reader.run {
        ...
        val remarks = readString(RESPONSE_FIELDS[3])!!
        val is_income = readBoolean(RESPONSE_FIELDS[4])!!
        // De-Serializing the field here
        val created_at = readCustomType<Any>(RESPONSE_FIELDS[5] as ResponseField.CustomTypeField)!!
        Expense(
                    ...
        )
      }
      ...
    }
  }
Enter fullscreen mode Exit fullscreen mode

Though, on surface created_on marked as Any field, under the hood it leaves markers indicating this type can be changed to some concrete type.

Let's convert the timestampz.


How do I translate timestamp to Date object?

To convert the timestampz to Date, we need both compiler and apollo client to work together. Solution consists of three parts.

  1. Choosing concrete type for custom scalar

  2. Mapping it in code-gen

  3. Attaching adapter to the apollo client

1. Choosing concrete type for custom scalar - [threetenbp lib]

My choice of DateTime library here is threetenbp. This ports Java SE 8 LocalDateTime classes to java 6 and 7. My initial feasibility check with kotlinx-datetime results didn't look so good as custom formatting of date time is not supported yet.

Add this gradle dependency to your app module.

// File: "app/build.gradle"

// https://mvnrepository.com/artifact/org.threeten/threetenbp
implementation "org.threeten:threetenbp:1.5.1"

Enter fullscreen mode Exit fullscreen mode

2. Code-gen changes - Apollo compile time setup to map timestamp

Compile time setup is simple. We must map the fully qualified target class name against our custom scalar. This is enough to convert our scalar to LocalDateTime.

// File: "app/build.gradle"

apollo {
    generateKotlinModels.set(true)
    customTypeMapping = [
            "timestamptz" : "org.threeten.bp.LocalDateTime"
    ]
}

dependencies {
...
Enter fullscreen mode Exit fullscreen mode

A peek through updated CustomType enum changes.

   TIMESTAMPTZ {
     override fun typeName(): String = "timestamptz"

-    override fun className(): String = "kotlin.Any"
+    override fun className(): String = "org.threeten.bp.LocalDateTime"
Enter fullscreen mode Exit fullscreen mode

And in Expense class, the field type and deserializer changed.

-------- 
+++ 
import org.threeten.bp.LocalDateTime

@@ -1,13 +1,13 @@
 data class Expense(
     val __typename: String = "expenses",
     val id: Int,
     val amount: Int,
     val remarks: String,
     val is_income: Boolean,
-    val created_at: Any
+    val created_at: LocalDateTime // Now it's concrete
   ) {

         // Field deserialization - now takes LocalDateTime
         // instead of Any
-        val created_at = readCustomType<Any>(RESPONSE_FIELDS[5] as
+        val created_at = readCustomType<LocalDateTime>(RESPONSE_FIELDS[5] as
             ResponseField.CustomTypeField)!!
Enter fullscreen mode Exit fullscreen mode

Now we have the type changed in generated classes. Yet, at runtime apollo doesn't know how to create a LocalDateTime object from a timestamptz. Let's provide the adapter in next step.

3. Apollo runtime setup for parsing date

This step is to convert string to Date and vice-verse. Threetenbp has DateTimeFormatter formatter class for this purpose. Let's have a look at our sample date-time from our backend


2021-05-13T04:00:49.815194+00:00

Enter fullscreen mode Exit fullscreen mode

This is of pattern yyyy-MM-dd'T'HH:mm:ss.SSSSSSz. Let's create a formatter for this.


val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSSz")

Enter fullscreen mode Exit fullscreen mode

Now we create CustomTypeAdapter object to attach with apollo client. It has decode - encode functions to carry out the conversions. Using the above formatter, we can create an adapter like this,

/**
 * Timestamp delivered as string from API. This adapter takes care of encode / decode the same.
 */
private val timeStampAdapter = object : CustomTypeAdapter<LocalDateTime> {

    private val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSSz")

    override fun decode(value: CustomTypeValue<*>): LocalDateTime {
        return try {
            LocalDateTime.parse(value.value.toString(), formatter)
        } catch (e: Exception) {
            throw RuntimeException("Cannot parse date: ${value.value}")
        }
    }

    override fun encode(value: LocalDateTime): CustomTypeValue<*> {
        return try {
            CustomTypeValue.GraphQLString(formatter.format(value))
        } catch (e: Exception) {
            throw RuntimeException("Cannot serialize the date date: ${value}")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Now map the adapter to the enum through ApolloClient.Builder.


val apolloClient = ApolloClient
        .builder()
        .serverUrl("// url //")
        .addCustomTypeAdapter(CustomType.TIMESTAMPTZ, timeStampAdapter)
        .build()

Enter fullscreen mode Exit fullscreen mode

That's it!! Now ApolloClient knows timestamp is LocalDateTime and we get a solid generated class to use in domain layer.

Overall changes for the implementation are available as gist


Endnote

Now we've seen how to write adapters for a scalar, you might be in urge to create adapters for Enums. Don't do it. Push it back to the backend and ask them to define it in schema. Apollo could generate enums if schema defined properly.

Discussion (0)