From 6afc9fff8d5a18e7a75145e236fd42d173e0d70d Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Tue, 5 Mar 2024 17:09:38 -0500 Subject: [PATCH] Improved message sending and draft create/update performance (#217) # Description This PR improves message send/draft create/update performance by always defaulting to application/json instead of multipart. Multipart will only be used for when a request contains a total attachments size of 3mb or higher. # License I confirm that this contribution is made under the terms of the MIT license and that I have the authority necessary to make this contribution on behalf of its copyright owner. --- CHANGELOG.md | 1 + src/main/kotlin/com/nylas/resources/Drafts.kt | 42 +++-- .../kotlin/com/nylas/resources/Messages.kt | 21 ++- src/main/kotlin/com/nylas/util/FileUtils.kt | 6 + .../kotlin/com/nylas/resources/DraftsTests.kt | 177 +++++++++++++++++- .../com/nylas/resources/MessagesTests.kt | 94 +++++++++- 6 files changed, 317 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9664b744..7381ee80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased ### Changed +* Improved message sending and draft create/update performance * Change default timeout to match API (90 seconds) ## [2.2.0] - Released 2024-02-27 diff --git a/src/main/kotlin/com/nylas/resources/Drafts.kt b/src/main/kotlin/com/nylas/resources/Drafts.kt index 1981b48e..107c34dc 100644 --- a/src/main/kotlin/com/nylas/resources/Drafts.kt +++ b/src/main/kotlin/com/nylas/resources/Drafts.kt @@ -41,15 +41,22 @@ class Drafts(client: NylasClient) : Resource(client, Draft::class.java) { @Throws(NylasApiError::class, NylasSdkTimeoutError::class) fun create(identifier: String, requestBody: CreateDraftRequest): Response { val path = String.format("v3/grants/%s/drafts", identifier) - - val attachmentLessPayload = requestBody.copy(attachments = null) - val serializedRequestBody = JsonHelper.moshi() - .adapter(CreateDraftRequest::class.java) - .toJson(attachmentLessPayload) - val multipart = FileUtils.buildFormRequest(requestBody, serializedRequestBody) val responseType = Types.newParameterizedType(Response::class.java, Draft::class.java) + val adapter = JsonHelper.moshi().adapter(CreateDraftRequest::class.java) + + // Use form data only if the attachment size is greater than 3mb + val attachmentSize = requestBody.attachments?.sumOf { it.size } ?: 0 + + return if (attachmentSize >= FileUtils.MAXIMUM_JSON_ATTACHMENT_SIZE) { + val attachmentLessPayload = requestBody.copy(attachments = null) + val serializedRequestBody = adapter.toJson(attachmentLessPayload) + val multipart = FileUtils.buildFormRequest(requestBody, serializedRequestBody) - return client.executeFormRequest(path, NylasClient.HttpMethod.POST, multipart, responseType) + client.executeFormRequest(path, NylasClient.HttpMethod.POST, multipart, responseType) + } else { + val serializedRequestBody = adapter.toJson(requestBody) + createResource(path, serializedRequestBody) + } } /** @@ -62,15 +69,22 @@ class Drafts(client: NylasClient) : Resource(client, Draft::class.java) { @Throws(NylasApiError::class, NylasSdkTimeoutError::class) fun update(identifier: String, draftId: String, requestBody: UpdateDraftRequest): Response { val path = String.format("v3/grants/%s/drafts/%s", identifier, draftId) - - val attachmentLessPayload = requestBody.copy(attachments = null) - val serializedRequestBody = JsonHelper.moshi() - .adapter(UpdateDraftRequest::class.java) - .toJson(attachmentLessPayload) - val multipart = FileUtils.buildFormRequest(requestBody, serializedRequestBody) val responseType = Types.newParameterizedType(Response::class.java, Draft::class.java) + val adapter = JsonHelper.moshi().adapter(UpdateDraftRequest::class.java) + + // Use form data only if the attachment size is greater than 3mb + val attachmentSize = requestBody.attachments?.sumOf { it.size } ?: 0 + + return if (attachmentSize >= FileUtils.MAXIMUM_JSON_ATTACHMENT_SIZE) { + val attachmentLessPayload = requestBody.copy(attachments = null) + val serializedRequestBody = adapter.toJson(attachmentLessPayload) + val multipart = FileUtils.buildFormRequest(requestBody, serializedRequestBody) - return client.executeFormRequest(path, NylasClient.HttpMethod.PUT, multipart, responseType) + client.executeFormRequest(path, NylasClient.HttpMethod.PUT, multipart, responseType) + } else { + val serializedRequestBody = adapter.toJson(requestBody) + updateResource(path, serializedRequestBody) + } } /** diff --git a/src/main/kotlin/com/nylas/resources/Messages.kt b/src/main/kotlin/com/nylas/resources/Messages.kt index 7c794d2c..4d53e4de 100644 --- a/src/main/kotlin/com/nylas/resources/Messages.kt +++ b/src/main/kotlin/com/nylas/resources/Messages.kt @@ -76,15 +76,22 @@ class Messages(client: NylasClient) : Resource(client, Message::class.j @Throws(NylasApiError::class, NylasSdkTimeoutError::class) fun send(identifier: String, requestBody: SendMessageRequest): Response { val path = String.format("v3/grants/%s/messages/send", identifier) - - val attachmentLessPayload = requestBody.copy(attachments = null) - val serializedRequestBody = JsonHelper.moshi() - .adapter(SendMessageRequest::class.java) - .toJson(attachmentLessPayload) - val multipart = FileUtils.buildFormRequest(requestBody, serializedRequestBody) val responseType = Types.newParameterizedType(Response::class.java, Message::class.java) + val adapter = JsonHelper.moshi().adapter(SendMessageRequest::class.java) + + // Use form data only if the attachment size is greater than 3mb + val attachmentSize = requestBody.attachments?.sumOf { it.size } ?: 0 + + return if (attachmentSize >= FileUtils.MAXIMUM_JSON_ATTACHMENT_SIZE) { + val attachmentLessPayload = requestBody.copy(attachments = null) + val serializedRequestBody = adapter.toJson(attachmentLessPayload) + val multipart = FileUtils.buildFormRequest(requestBody, serializedRequestBody) - return client.executeFormRequest(path, NylasClient.HttpMethod.POST, multipart, responseType) + client.executeFormRequest(path, NylasClient.HttpMethod.POST, multipart, responseType) + } else { + val serializedRequestBody = adapter.toJson(requestBody) + createResource(path, serializedRequestBody) + } } /** diff --git a/src/main/kotlin/com/nylas/util/FileUtils.kt b/src/main/kotlin/com/nylas/util/FileUtils.kt index 0c1c1d1b..485c2299 100644 --- a/src/main/kotlin/com/nylas/util/FileUtils.kt +++ b/src/main/kotlin/com/nylas/util/FileUtils.kt @@ -14,6 +14,12 @@ import java.nio.file.Paths class FileUtils { companion object { + /** + * The maximum size of an attachment that can be sent using json. + */ + @JvmStatic + val MAXIMUM_JSON_ATTACHMENT_SIZE = 3 * 1024 * 1024 + /** * Converts an [InputStream] into a streaming [RequestBody] for use with [okhttp3] requests. * diff --git a/src/test/kotlin/com/nylas/resources/DraftsTests.kt b/src/test/kotlin/com/nylas/resources/DraftsTests.kt index b6c27bcb..1918ace2 100644 --- a/src/test/kotlin/com/nylas/resources/DraftsTests.kt +++ b/src/test/kotlin/com/nylas/resources/DraftsTests.kt @@ -15,6 +15,7 @@ import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import java.io.ByteArrayInputStream import java.lang.reflect.Type import kotlin.test.Test import kotlin.test.assertEquals @@ -227,6 +228,92 @@ class DraftsTests { drafts.create(grantId, createDraftRequest) + val pathCaptor = argumentCaptor() + val typeCaptor = argumentCaptor() + val requestBodyCaptor = argumentCaptor() + val queryParamCaptor = argumentCaptor() + verify(mockNylasClient).executePost>( + pathCaptor.capture(), + typeCaptor.capture(), + requestBodyCaptor.capture(), + queryParamCaptor.capture(), + ) + + assertEquals("v3/grants/$grantId/drafts", pathCaptor.firstValue) + assertEquals(Types.newParameterizedType(Response::class.java, Draft::class.java), typeCaptor.firstValue) + assertEquals(adapter.toJson(createDraftRequest), requestBodyCaptor.firstValue) + assertNull(queryParamCaptor.firstValue) + } + + @Test + fun `creating a draft with small attachment calls requests with the correct params`() { + val adapter = JsonHelper.moshi().adapter(CreateDraftRequest::class.java) + val testInputStream = ByteArrayInputStream("test data".toByteArray()) + val createDraftRequest = + CreateDraftRequest( + body = "Hello, I just sent a message using Nylas!", + cc = listOf(EmailName(email = "test@gmail.com", name = "Test")), + bcc = listOf(EmailName(email = "bcc@gmail.com", name = "BCC")), + subject = "Hello from Nylas!", + starred = true, + sendAt = 1620000000, + replyToMessageId = "reply-to-message-id", + trackingOptions = TrackingOptions(label = "label", links = true, opens = true, threadReplies = true), + attachments = listOf( + CreateAttachmentRequest( + content = testInputStream, + contentType = "text/plain", + filename = "attachment.txt", + size = 100, + ), + ), + ) + + drafts.create(grantId, createDraftRequest) + + val pathCaptor = argumentCaptor() + val typeCaptor = argumentCaptor() + val requestBodyCaptor = argumentCaptor() + val queryParamCaptor = argumentCaptor() + verify(mockNylasClient).executePost>( + pathCaptor.capture(), + typeCaptor.capture(), + requestBodyCaptor.capture(), + queryParamCaptor.capture(), + ) + + assertEquals("v3/grants/$grantId/drafts", pathCaptor.firstValue) + assertEquals(Types.newParameterizedType(Response::class.java, Draft::class.java), typeCaptor.firstValue) + assertEquals(adapter.toJson(createDraftRequest), requestBodyCaptor.firstValue) + assertNull(queryParamCaptor.firstValue) + } + + @Test + fun `creating a draft with large attachment calls requests with the correct params`() { + val adapter = JsonHelper.moshi().adapter(CreateDraftRequest::class.java) + val testInputStream = ByteArrayInputStream("test data".toByteArray()) + val createDraftRequest = + CreateDraftRequest( + body = "Hello, I just sent a message using Nylas!", + cc = listOf(EmailName(email = "test@gmail.com", name = "Test")), + bcc = listOf(EmailName(email = "bcc@gmail.com", name = "BCC")), + subject = "Hello from Nylas!", + starred = true, + sendAt = 1620000000, + replyToMessageId = "reply-to-message-id", + trackingOptions = TrackingOptions(label = "label", links = true, opens = true, threadReplies = true), + attachments = listOf( + CreateAttachmentRequest( + content = testInputStream, + contentType = "text/plain", + filename = "attachment.txt", + size = 3 * 1024 * 1024, + ), + ), + ) + + drafts.create(grantId, createDraftRequest) + val pathCaptor = argumentCaptor() val methodCaptor = argumentCaptor() val typeCaptor = argumentCaptor() @@ -245,10 +332,13 @@ class DraftsTests { assertEquals(NylasClient.HttpMethod.POST, methodCaptor.firstValue) assertNull(queryParamCaptor.firstValue) val multipart = requestBodyCaptor.firstValue as MultipartBody - assertEquals(1, multipart.size()) + assertEquals(2, multipart.size()) val buffer = Buffer() + val fileBuffer = Buffer() multipart.part(0).body().writeTo(buffer) + multipart.part(1).body().writeTo(fileBuffer) assertEquals(adapter.toJson(createDraftRequest), buffer.readUtf8()) + assertEquals("test data", fileBuffer.readUtf8()) } @Test @@ -265,6 +355,86 @@ class DraftsTests { drafts.update(grantId, draftId, updateDraftRequest) + val pathCaptor = argumentCaptor() + val typeCaptor = argumentCaptor() + val requestBodyCaptor = argumentCaptor() + val queryParamCaptor = argumentCaptor() + verify(mockNylasClient).executePut>( + pathCaptor.capture(), + typeCaptor.capture(), + requestBodyCaptor.capture(), + queryParamCaptor.capture(), + ) + + assertEquals("v3/grants/$grantId/drafts/$draftId", pathCaptor.firstValue) + assertEquals(Types.newParameterizedType(Response::class.java, Draft::class.java), typeCaptor.firstValue) + assertEquals(adapter.toJson(updateDraftRequest), requestBodyCaptor.firstValue) + assertNull(queryParamCaptor.firstValue) + } + + @Test + fun `updating a draft with small attachments calls requests with the correct params`() { + val draftId = "draft-123" + val adapter = JsonHelper.moshi().adapter(UpdateDraftRequest::class.java) + val testInputStream = ByteArrayInputStream("test data".toByteArray()) + val updateDraftRequest = + UpdateDraftRequest( + body = "Hello, I just sent a message using Nylas!", + subject = "Hello from Nylas!", + unread = false, + starred = true, + attachments = listOf( + CreateAttachmentRequest( + content = testInputStream, + contentType = "text/plain", + filename = "attachment.txt", + size = 100, + ), + ), + ) + + drafts.update(grantId, draftId, updateDraftRequest) + + val pathCaptor = argumentCaptor() + val typeCaptor = argumentCaptor() + val requestBodyCaptor = argumentCaptor() + val queryParamCaptor = argumentCaptor() + verify(mockNylasClient).executePut>( + pathCaptor.capture(), + typeCaptor.capture(), + requestBodyCaptor.capture(), + queryParamCaptor.capture(), + ) + + assertEquals("v3/grants/$grantId/drafts/$draftId", pathCaptor.firstValue) + assertEquals(Types.newParameterizedType(Response::class.java, Draft::class.java), typeCaptor.firstValue) + assertEquals(adapter.toJson(updateDraftRequest), requestBodyCaptor.firstValue) + assertNull(queryParamCaptor.firstValue) + } + + @Test + fun `updating a draft with large attachments calls requests with the correct params`() { + val draftId = "draft-123" + val adapter = JsonHelper.moshi().adapter(UpdateDraftRequest::class.java) + val testInputStream = ByteArrayInputStream("test data".toByteArray()) + val updateDraftRequest = + UpdateDraftRequest( + body = "Hello, I just sent a message using Nylas!", + subject = "Hello from Nylas!", + unread = false, + starred = true, + attachments = listOf( + CreateAttachmentRequest( + content = testInputStream, + contentType = "text/plain", + filename = "attachment.txt", + size = 3 * 1024 * 1024, + ), + ), + ) + + drafts.update(grantId, draftId, updateDraftRequest) + val pathCaptor = argumentCaptor() val methodCaptor = argumentCaptor() val typeCaptor = argumentCaptor() @@ -283,10 +453,13 @@ class DraftsTests { assertEquals(NylasClient.HttpMethod.PUT, methodCaptor.firstValue) assertNull(queryParamCaptor.firstValue) val multipart = requestBodyCaptor.firstValue as MultipartBody - assertEquals(1, multipart.size()) + assertEquals(2, multipart.size()) val buffer = Buffer() + val fileBuffer = Buffer() multipart.part(0).body().writeTo(buffer) + multipart.part(1).body().writeTo(fileBuffer) assertEquals(adapter.toJson(updateDraftRequest), buffer.readUtf8()) + assertEquals("test data", fileBuffer.readUtf8()) } @Test diff --git a/src/test/kotlin/com/nylas/resources/MessagesTests.kt b/src/test/kotlin/com/nylas/resources/MessagesTests.kt index 62598c70..f7783d17 100644 --- a/src/test/kotlin/com/nylas/resources/MessagesTests.kt +++ b/src/test/kotlin/com/nylas/resources/MessagesTests.kt @@ -12,6 +12,7 @@ import org.junit.jupiter.api.Nested import org.mockito.Mockito import org.mockito.MockitoAnnotations import org.mockito.kotlin.* +import java.io.ByteArrayInputStream import java.lang.reflect.Type import kotlin.test.Test import kotlin.test.assertEquals @@ -371,6 +372,94 @@ class MessagesTests { messages.send(grantId, sendMessageRequest) + val pathCaptor = argumentCaptor() + val typeCaptor = argumentCaptor() + val requestBodyCaptor = argumentCaptor() + val queryParamCaptor = argumentCaptor() + verify(mockNylasClient).executePost>( + pathCaptor.capture(), + typeCaptor.capture(), + requestBodyCaptor.capture(), + queryParamCaptor.capture(), + ) + + assertEquals("v3/grants/$grantId/messages/send", pathCaptor.firstValue) + assertEquals(Types.newParameterizedType(Response::class.java, Message::class.java), typeCaptor.firstValue) + assertEquals(adapter.toJson(sendMessageRequest), requestBodyCaptor.firstValue) + assertNull(queryParamCaptor.firstValue) + } + + @Test + fun `sending a message with a small attachment calls requests with the correct params`() { + val adapter = JsonHelper.moshi().adapter(SendMessageRequest::class.java) + val testInputStream = ByteArrayInputStream("test data".toByteArray()) + val sendMessageRequest = + SendMessageRequest( + to = listOf(EmailName(email = "test@gmail.com", name = "Test")), + body = "Hello, I just sent a message using Nylas!", + cc = listOf(EmailName(email = "test@gmail.com", name = "Test")), + bcc = listOf(EmailName(email = "bcc@gmail.com", name = "BCC")), + subject = "Hello from Nylas!", + starred = true, + sendAt = 1620000000, + replyToMessageId = "reply-to-message-id", + trackingOptions = TrackingOptions(label = "label", links = true, opens = true, threadReplies = true), + attachments = listOf( + CreateAttachmentRequest( + content = testInputStream, + contentType = "text/plain", + filename = "attachment.txt", + size = 100, + ), + ), + ) + + messages.send(grantId, sendMessageRequest) + + val pathCaptor = argumentCaptor() + val typeCaptor = argumentCaptor() + val requestBodyCaptor = argumentCaptor() + val queryParamCaptor = argumentCaptor() + verify(mockNylasClient).executePost>( + pathCaptor.capture(), + typeCaptor.capture(), + requestBodyCaptor.capture(), + queryParamCaptor.capture(), + ) + + assertEquals("v3/grants/$grantId/messages/send", pathCaptor.firstValue) + assertEquals(Types.newParameterizedType(Response::class.java, Message::class.java), typeCaptor.firstValue) + assertEquals(adapter.toJson(sendMessageRequest), requestBodyCaptor.firstValue) + assertNull(queryParamCaptor.firstValue) + } + + @Test + fun `sending a message with a large attachment calls requests with the correct params`() { + val adapter = JsonHelper.moshi().adapter(SendMessageRequest::class.java) + val testInputStream = ByteArrayInputStream("test data".toByteArray()) + val sendMessageRequest = + SendMessageRequest( + to = listOf(EmailName(email = "test@gmail.com", name = "Test")), + body = "Hello, I just sent a message using Nylas!", + cc = listOf(EmailName(email = "test@gmail.com", name = "Test")), + bcc = listOf(EmailName(email = "bcc@gmail.com", name = "BCC")), + subject = "Hello from Nylas!", + starred = true, + sendAt = 1620000000, + replyToMessageId = "reply-to-message-id", + trackingOptions = TrackingOptions(label = "label", links = true, opens = true, threadReplies = true), + attachments = listOf( + CreateAttachmentRequest( + content = testInputStream, + contentType = "text/plain", + filename = "attachment.txt", + size = 3 * 1024 * 1024, + ), + ), + ) + + messages.send(grantId, sendMessageRequest) + val pathCaptor = argumentCaptor() val methodCaptor = argumentCaptor() val typeCaptor = argumentCaptor() @@ -389,10 +478,13 @@ class MessagesTests { assertEquals(NylasClient.HttpMethod.POST, methodCaptor.firstValue) assertNull(queryParamCaptor.firstValue) val multipart = requestBodyCaptor.firstValue as MultipartBody - assertEquals(1, multipart.size()) + assertEquals(2, multipart.size()) val buffer = Buffer() + val fileBuffer = Buffer() multipart.part(0).body().writeTo(buffer) + multipart.part(1).body().writeTo(fileBuffer) assertEquals(adapter.toJson(sendMessageRequest), buffer.readUtf8()) + assertEquals("test data", fileBuffer.readUtf8()) } }