Home Get productive with GraphQL — Type Adapters
Post
Cancel

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.

Usecase 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.

1
2
3
4
5
6
7
8
9
// File: "queries.graphql"

expenses {         ## node
    id
    amount
    remarks
    is_income
    created_at     ## Custom scalar
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 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"
},

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 look like this.

1
2
3
4
5
6
7
8
9
10
11
12
13
// 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"
  }
}

Generated class for Expense node look 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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// 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(
					...
        )
      }
      ...
    }
  }

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 consist 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.

1
2
3
4
5
// File: "app/build.gradle"

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

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

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

1
2
3
4
5
6
7
8
9
10
11
// File: "app/build.gradle"

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

dependencies {
...

A peek through updated CustomType enum changes.

1
2
3
4
5
   TIMESTAMPTZ {
     override fun typeName(): String = "timestamptz"
 
-    override fun className(): String = "kotlin.Any"
+    override fun className(): String = "org.threeten.bp.LocalDateTime"

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
--- 
+++ 
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)!!

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

1
2
3
2021-05-13T04:00:49.815194+00:00

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

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

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,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
 * 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}")
        }
    }
}

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

1
2
3
4
5
6
7
val apolloClient = ApolloClient
        .builder()
        .serverUrl("// url //")
        .addCustomTypeAdapter(CustomType.TIMESTAMPTZ, timeStampAdapter)
        .build()

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

Gist

Overall changes for the implementation is 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 can generate enums if it is defined properly.

This post is licensed under CC BY 4.0 by the author.