Skip to content

Commit

Permalink
Improved message sending and draft create/update performance (#217)
Browse files Browse the repository at this point in the history
# 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
<!-- Your PR comment must contain the following line for us to merge the
PR. -->
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.
  • Loading branch information
mrashed-dev authored Mar 5, 2024
1 parent 88da3b5 commit 6afc9ff
Show file tree
Hide file tree
Showing 6 changed files with 317 additions and 24 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 28 additions & 14 deletions src/main/kotlin/com/nylas/resources/Drafts.kt
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,22 @@ class Drafts(client: NylasClient) : Resource<Draft>(client, Draft::class.java) {
@Throws(NylasApiError::class, NylasSdkTimeoutError::class)
fun create(identifier: String, requestBody: CreateDraftRequest): Response<Draft> {
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)
}
}

/**
Expand All @@ -62,15 +69,22 @@ class Drafts(client: NylasClient) : Resource<Draft>(client, Draft::class.java) {
@Throws(NylasApiError::class, NylasSdkTimeoutError::class)
fun update(identifier: String, draftId: String, requestBody: UpdateDraftRequest): Response<Draft> {
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)
}
}

/**
Expand Down
21 changes: 14 additions & 7 deletions src/main/kotlin/com/nylas/resources/Messages.kt
Original file line number Diff line number Diff line change
Expand Up @@ -76,15 +76,22 @@ class Messages(client: NylasClient) : Resource<Message>(client, Message::class.j
@Throws(NylasApiError::class, NylasSdkTimeoutError::class)
fun send(identifier: String, requestBody: SendMessageRequest): Response<Message> {
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)
}
}

/**
Expand Down
6 changes: 6 additions & 0 deletions src/main/kotlin/com/nylas/util/FileUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
177 changes: 175 additions & 2 deletions src/test/kotlin/com/nylas/resources/DraftsTests.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -227,6 +228,92 @@ class DraftsTests {

drafts.create(grantId, createDraftRequest)

val pathCaptor = argumentCaptor<String>()
val typeCaptor = argumentCaptor<Type>()
val requestBodyCaptor = argumentCaptor<String>()
val queryParamCaptor = argumentCaptor<IQueryParams>()
verify(mockNylasClient).executePost<Response<Draft>>(
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 = "[email protected]", name = "Test")),
bcc = listOf(EmailName(email = "[email protected]", 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<String>()
val typeCaptor = argumentCaptor<Type>()
val requestBodyCaptor = argumentCaptor<String>()
val queryParamCaptor = argumentCaptor<IQueryParams>()
verify(mockNylasClient).executePost<Response<Draft>>(
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 = "[email protected]", name = "Test")),
bcc = listOf(EmailName(email = "[email protected]", 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<String>()
val methodCaptor = argumentCaptor<NylasClient.HttpMethod>()
val typeCaptor = argumentCaptor<Type>()
Expand All @@ -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
Expand All @@ -265,6 +355,86 @@ class DraftsTests {

drafts.update(grantId, draftId, updateDraftRequest)

val pathCaptor = argumentCaptor<String>()
val typeCaptor = argumentCaptor<Type>()
val requestBodyCaptor = argumentCaptor<String>()
val queryParamCaptor = argumentCaptor<IQueryParams>()
verify(mockNylasClient).executePut<Response<Draft>>(
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<String>()
val typeCaptor = argumentCaptor<Type>()
val requestBodyCaptor = argumentCaptor<String>()
val queryParamCaptor = argumentCaptor<IQueryParams>()
verify(mockNylasClient).executePut<Response<Draft>>(
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<String>()
val methodCaptor = argumentCaptor<NylasClient.HttpMethod>()
val typeCaptor = argumentCaptor<Type>()
Expand All @@ -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
Expand Down
Loading

0 comments on commit 6afc9ff

Please sign in to comment.