Skip to content

Blog


Building an Image Upload Endpoint in a gRPC and Kotlin Stack

October 16, 2020

|

Abdalla Salia

When moving to a Kotlin gRPC framework for backend services, handling image data can be challenging. Image data is larger than typical payloads and often needs facilitated partial uploads in case of network issues or latency problems. For these reasons, simple unary requests to upload images on a backend framework are not reliable. There also needs to be a way to manipulate binary data so image data can be attached to a request made to a REST API. 

DoorDash had to overcome these image data handling issues when the Merchant team was rolling out its new storefront experience, which enabled restaurants to brand their online ordering site. 

When creating storefront experiences, many restaurants wanted to upload custom logos, which could only be done manually. To automate this problem we created a new endpoint that could handle image data and communicate with REST APIs. In the end we were able to successfully create an image endpoint that could communicate with REST APIs and effectively automate the merchant custom logo uploading process. 

The image uploading challenge for DoorDash Storefront

At DoorDash, we needed to be able to upload custom logos for our Storefront initiative, which was challenging given our Kotlin gRPC stack. The problem was that each merchant’s storefront site was automatically generated with a generic logo taken from the businesses’ public assets. Relying on this automated process meant that when merchants asked to use custom logos for their storefront sites, we had no way to easily upload the new images and had to resort to doing it manually. 

Initially, we were able to support custom logos by manually storing the image data using an endpoint in the Merchant Data Service (MDS; our main microservice for managing all merchant data) and manually updating the URL data for a store in our database for each logo change request. We needed to find a scalable and less time-consuming solution as more merchants started using the service and logo update requests were becoming more frequent.  

To automate image uploading for our Storefront, we created an internal tool/endpoint that could handle image data requests and communicate with the MDS, which is a REST service. In implementing this tool, we had to think about two main things: 

  • How do we efficiently intake and parse image data?  
  • After the intake of image data, what is the best way to transmit image data from our gRPC service to MDS? 

Creating an image upload tool in a gRPC service that can make REST API calls

Our solution was to create a client streaming endpoint in our gRPC service. Since there was a need to send this image data to a REST service, we created a RequestBody entity that wraps around the binary image data to attach the image data to a POST request. To send data from a gRPC client to a REST server would require the use of an HTTP client framework. In our case, we used OkHttp to build this REST client that allows our gRPC service to make requests to REST services. Using the OkHttp library, we built a client that allows us to make client calls to the MDS, and the image data was then sent as part of a multipart request. 

Creating a client streaming endpoint

The first step in creating a client steaming endpoint is to define the endpoint parameters in a protocol buffer (protobuf) file. For this use case, we define the endpoint as an RPC that takes an UploadThemeImagesRequest entity as a request object and returns an UploadThemeImagesResponse entity as the response object. Notice that in defining a client streaming endpoint, we prefix the request entity with the “stream” keyword. This is the major difference in defining an RPC for a client streaming endpoint and a simple unary endpoint, otherwise, they are defined in the same way:

rpc UploadThemeImages(stream UploadThemeImagesRequest) returns (UploadThemeImagesResponse) {};

Next, to explicitly define our request and response entities, we need to define them as messages in the .proto file. Note that we could also import message definitions in the event that there is a generic message that has already been defined in another .proto file.

message UploadThemeImagesRequest {
 int64 entity_id = 1;
 EntityType entity_type = 2;
 bytes logo_image_file = 3;
 bytes favicon_image_file = 4;
}

The request entity UploadThemeImageRequest has four fields. Entity_id and entity_type are metadata associated with images. Logo_image_file and favicon_image_file are nullable fields that store the binary image data related to store logos and favicons respectively.

message  UploadThemeImagesResponse {
 string logo_url = 1;
 string favicon_url = 2;
}

The response entity UploadThemeImageResponse has two fields. It returns links to the most recent logo and favicon images related to a store.

The main reason for implementing the image upload endpoint as a client streaming RPC is because we want to take in the image data in bits called chunks. Chunking is a great way to handle binary image data because it allows for partial uploads, and we can track upload progress in case we want to add such a feature to the frontend clients. Chunks are also great because we use less memory per chunk of the request sent. Defining our endpoint as a client streaming RPC will provide a mechanism for intaking binary data as a stream of chunks. 

Once the RPC definitions are set up in our .proto file, the next step in our implementation is to generate a server side stub and implement the logic for our endpoint.

Parsing binary image data in your gRPC service

On the server side, the request from a client call is going to come through as a RecieveChannel object. This stream allows us to receive the requests in chunks. The snippet below shows the function that handles the incoming request. We will now go through and identify relevant parts of the logic.

suspend fun uploadThemeImages(
   requestChannel: ReceiveChannel<StorefrontInternalProtos.UploadThemeImagesRequest>
): StorefrontInternalProtos.UploadThemeImagesResponse {
   val faviconImage = ByteArrayOutputStream()
   val logoImage = ByteArrayOutputStream()

   val request = requestChannel.receive()
   val entityId = request.entityId
   val entityType = request.entityType.name

   request.faviconImageFile.writeTo(faviconImage)
   request.logoImageFile.writeTo(logoImage)

   for (chunk in requestChannel) {
       chunk.faviconImageFile.writeTo(faviconImage)
       chunk.logoImageFile.writeTo(logoImage)
   }

   val faviconByteArray = faviconImage.toByteArray()
   val logoByteArray = logoImage.toByteArray()

   val favicon = when (faviconByteArray.size) {
       0 -> DEFAULT_PHOTO_ENTITY
       else -> merchantDataServiceRepository.uploadThemeImage(entityId, faviconByteArray).throwOnFailure()
   }
   val logo = when (logoByteArray.size) {
       0 -> DEFAULT_PHOTO_ENTITY
       else -> merchantDataServiceRepository.uploadThemeImage(entityId, logoByteArray).throwOnFailure()
   }

   val themeWithNewDetails = Theme(
           entityId = entityId,
           entityType = entityType,
           faviconImage = favicon.id?.toString(),
           logoImage = logo.id?.toString()
   )
   themeRepository.updateTheme(themeWithNewDetails).throwOnFailure()

   return StorefrontInternalProtos.UploadThemeImagesResponse
           .newBuilder()
           .setFaviconUrl(favicon.imageUrl ?: "")
           .setLogoUrl(logo.imageUrl ?: "")
           .build()
}

Since the client call passes image data in the form of binary data, we initialize an output stream, ByteArrayOutputStream, to write binary data to. In the code snippet above, notice that we initialize the output streams as:

 val faviconImage = ByteArrayOutputStream()
  val logoImage = ByteArrayOutputStream()

To read the data from the incoming request stream, we iterate through the RecieveChannel request with a simple for loop. The for loop automatically terminates once the client stops sending data through the stream. Notice that the request comes with metadata. To store that metadata in memory without having to re-assign a variable every time in the for loop, we receive the first chunk of data with ReceiveChannel.receive() and assign them to  entityId, entityType variables. ReceiveChannel.receive() is another means of taking in chunks from a data stream.The first chunk also comes with some image data so we need to write that to the output stream:

 val request = requestChannel.receive()
   val entityId = request.entityId
   val entityType = request.entityType.name

   request.faviconImageFile.writeTo(faviconImage)
   request.logoImageFile.writeTo(logoImage)

Next, we run a for loop to continually receive subsequent binary image data until the client stops transmitting data through the stream. For every chunk we will read the faviconImageFile and logoImageFile fields and write the binary data to output streams, as shown below:

for (chunk in requestChannel) {
       chunk.faviconImageFile.writeTo(faviconImage)
       chunk.logoImageFile.writeTo(logoImage)
   }

Once the loop is done executing, all the binary image data would have been written to the outputStream. To bundle up the data we have compiled from the stream, we simply convert the data in the ByteArrayOutputStream to a byteArray like so:

val faviconByteArray = faviconImage.toByteArray()
val logoByteArray = logoImage.toByteArray()

At this point what is done with the image data depends on the use case. For our team, an integral part of the subsequent logic was sending this data to MDS which is a REST service. To upload the image data to MDS, we call the merchantDataServiceRepository.uploadThemeImage method and pass the logo or favicon image data and entity_id as arguments. This function abstracts the functionality of sending the image data using a REST client. We delve more into how this method sends client requests in a subsequent paragraph.

Building a REST client in a gRPC service

Using OkHttp, an HTTP client framework, we built an HTTP client to send REST requests for our gRPC service, as shown below:

@Provides
fun getMdsClient(): MerchantDataServiceClient {
   val client = buildHttpClient()
           	.addInterceptor(ApiSecretInterceptor(System.getenv(MDS_API_KEY)))
      .build()

   val retrofit = Retrofit.Builder()
             .addConverterFactory(JacksonConverterFactory.create(OBJECT_MAPPER))
.client(client)
.baseUrl(System.getenv(MDS_API_URL))
.build()

   return retrofit.create(MerchantDataServiceClient::class.java)
}

private fun buildHttpClient(shouldRetryOnFailure: Boolean):         
       OkHttpClient.Builder {
             return OkHttpClient.Builder()
           .addInterceptor(BaseHeaderInterceptor())
           .addInterceptor(TimeoutInterceptor())
           .addInterceptor(HttpLoggingInterceptor().apply {
               level = HttpLoggingInterceptor.Level.NONE
           })
           .retryOnConnectionFailure(shouldRetryOnFailure)
}

In the function buildHttpClient, OkHttpClient.Builder() is used to build an HTTP client that enables sending requests to a REST service. Using .addInterceptor(), an interceptor can be added to intercept requests for specified reasons specific to your use case. There is also .retryOnConnection(), which determines whether the HTTP client should attempt to re-establish connection with a server when a request fails. After building the client, we must connect it to a server. In our case, MDS is the server. We connect the client to MDS using retrofit. 

Sending requests to a REST API using an Okhttp Client

Now that the REST client is set up, we are ready to send the image data as a post request to MDS. As mentioned earlier, we call the merchantDataServiceRepository.uploadThemeImage function to send the image data to MDS. The function is shown below:

fun uploadThemeImage(entityId: Long, imageFile: ByteArray): Outcome<PhotoEntity> {
   return MetricsUtil.measureMillisWithTimer(
           metrics.timer(UPLOAD_THEME_IMAGE_LATENCY)) {
       try {
           val requestBody = imageFile.toRequestBody(DEFAULT_IMAGE_TYPE.toMediaTypeOrNull(), 0, imageFile.size)
           val request = okhttp3.MultipartBody.Part.createFormData(
                                name = DEFAULT_IMAGE_NAME,
                                filename = DEFAULT_IMAGE_NAME,
                                body = requestBody)
           val response = mdsClient.uploadPhoto(request).execute()

           if (response.isSuccessful) {
response.body()?.let { Success(it) } ?:                  Failure(handleFailureOutcome(response))
           } else {
               Failure(handleFailureOutcome(response))
           }
       } catch (error: Exception) {
           logger.withValues(
                   METHOD to UPLOAD_THEME_IMAGE,
                   ENTITY_ID to entityId)
                   .error(MDS_UPLOAD_THEME_IMAGE_ERROR_MESSAGE.format(error, error.toStackTraceStr()))
           Failure(error)
       }
   }
}

Using OkHttp, we create a RequestBody entity that wraps around binary image data to be attached to client calls made to a REST service. 

val requestBody = imageFile.toRequestBody(DEFAULT_IMAGE_TYPE.toMediaTypeOrNull(), 0, imageFile.size)

Next, we create a multipart request using OkHttp and attach the image data. This is done by using okhttp3.MultipartBody.Part.createFormData as shown below. Notice that the RequestBody entity that was created above to wrap around the image data is passed as an argument to the body parameter of the request:

val request = okhttp3.MultipartBody.Part.createFormData(
                                name = DEFAULT_IMAGE_NAME,
                                filename = DEFAULT_IMAGE_NAME,
                                body = requestBody)

Notice that we pass the requestBody entity created earlier as an argument to the body parameter. After we create our request, we make a client connect to the MDS:

	val response = mdsClient.uploadPhoto(request).execute()

Now in our client interface, we define the function, mdsClient.uploadPhoto, that sends this request.This method sends a multipart request to a REST service with the okHttp client that was implemented earlier.

@Multipart
@POST("/api/v1/photos")
@Timeout(readTimeout = 10_000, writeTimeout = 10_000)
fun uploadPhoto(
   @Part imageFile: MultipartBody.Part
): Call<PhotoEntity>

The @Multipart annotation indicates to the OkHttp client that this is a muli-part request. The @Post annotation indicates to the Okhttp client that this is a POST request. The @Timeout annotation used here is a custom annotation we created to enable us set time out constraints to requests sent via the REST client. We go into detail about how we achieved this in the following section.

Setting dynamic timeout constraints on an OkHttp client using annotation

One thing worth noting is that OkHttp allows the setting of timing constraints on requests when initializing the client, but it does not support setting timeout constraints to specific requests dynamically. Being able to set dynamic timeouts for different requests proves useful since some requests to a server intrinsically take longer than others. 

In such a scenario, it makes sense to set different timing constraints for different requests rather than a generic timeout constraint. This allows us to better debug our client and ensure that all requests execute in a reasonable amount of time. To set up a dynamic timeout mechanism, we created a custom annotation @Timeout that takes three arguments. ReadTimeout, ConnectTimeout, and WriteTimeout

annotation class Timeout(
   val connectTimeout: Int = HTTP_CLIENT_CONNECT_TIMEOUT_MILLIS,
   val writeTimeout: Int = HTTP_CLIENT_WRITE_TIMEOUT_MILLIS,
   val readTimeout: Int = HTTP_CLIENT_READ_TIMEOUT_MILLIS
)

Using these parameters, we can set specific timeout constraints for a specific request, as shown:

@Timeout(readTimeout = 10_000, writeTimeout = 10_000)
fun uploadPhoto(
   @Part imageFile: MultipartBody.Part
): Call<PhotoEntity>

However, how do we actually get our client to apply these timeout constraints? We make use of an interceptor as mentioned earlier, which we add to the client on initialization.

OkHttpClient.Builder()
           .addInterceptor(TimeoutInterceptor())

The code snippet below shows how we implemented this interceptor: 

class TimeoutInterceptor : Interceptor {
   override fun intercept(chain: Interceptor.Chain): Response {
       val request = chain.request()
       val timeout = request.tag(Invocation::class.java)?.method()?.getAnnotation(Timeout::class.java)
       val newChain = chain
               .withReadTimeout(timeout?.readTimeout ?: HTTP_CLIENT_READ_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)
               .withConnectTimeout(timeout?.connectTimeout ?: HTTP_CLIENT_CONNECT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)
               .withWriteTimeout(timeout?.writeTimeout ?: HTTP_CLIENT_WRITE_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)

       return newChain.proceed(chain.request())
   }
}

The interceptor basically checks every outgoing request for a timeout annotation.  

val timeout = request.tag(Invocation::class.java)?.method()?.getAnnotation(Timeout::class.java)

The timeout variable is set to null when no such annotation is found associated with the request. We then set the custom timeout constraints if they were set using the annotation or simply set them to default values if the annotation is absent. After which we return the new request and its updated timeout constraints.

val newChain = chain
               .withReadTimeout(timeout?.readTimeout ?: HTTP_CLIENT_READ_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)
               .withConnectTimeout(timeout?.connectTimeout ?: HTTP_CLIENT_CONNECT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)
               .withWriteTimeout(timeout?.writeTimeout ?: HTTP_CLIENT_WRITE_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)

       return newChain.proceed(chain.request())
   }

Results: Storefront image uploads are now automated 

With this tool, the storefront team was able to improve efficiency by automating the time-consuming task of uploading custom logos and favicons to merchants’ stores on the platform. The image upload endpoint helped the team enhance the storefront experience for our merchants by giving them more control over their storefront instance.

In explaining how we dealt with this task on our Storefront team at DoorDash, we have shown how to create an image upload tool using a client-streaming endpoint. We also demonstrated how to communicate with a REST server from a gRPC service, and even more specifically, how to send image data as part of a multipart request to a REST endpoint. 

Abdallah Salia joined DoorDash for our 2020 Summer engineering internship program. DoorDash engineering interns integrate with teams to learn collaboration and deployment skills not generally taught in the classroom.

About the Author

  • Abdalla Salia

Related Jobs

Location
San Francisco, CA; Mountain View, CA; New York, NY; Seattle, WA
Department
Engineering
Location
San Francisco, CA; Sunnyvale, CA
Department
Engineering
Location
San Francisco, CA; Sunnyvale, CA; Seattle, WA
Department
Engineering
Location
Pune, India
Department
Engineering
Location
San Francisco, CA; Seattle, WA; Sunnyvale, CA
Department
Engineering