Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## [Unreleased]

### Fixed
* `UpdateEventRequest.Builder.clearColorId()` now sends `"color_id": null` to the Nylas API, allowing event colors to be cleared. Previously, setting `colorId` to `null` was silently omitted from the request body due to Moshi's default null-skipping behavior, leaving the existing color unchanged. (#324)
### Changed
* Extended `EventResource` with additional room metadata fields: `capacity` (`Int`), `building` (`String`), `floorName` / `floor_name` (`String`), `floorSection` / `floor_section` (`String`), and `floorNumber` / `floor_number` (`Int`) — all optional, defaulting to `null`

Expand Down
17 changes: 17 additions & 0 deletions src/main/kotlin/com/nylas/models/NullableField.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.nylas.models

/**
* Represents a field that can be explicitly set to null (to clear its value on the server)
* or left absent from the request payload entirely.
*
* - Leave the field as Kotlin null to omit it from serialization (no change to server value).
* - Use [Clear] to send `null` explicitly and clear the server-side value.
* - Use [Value] to send a specific value.
*/
sealed class NullableField<out T> {
/** Serialize the field as JSON null, signalling the server to clear the value. */
object Clear : NullableField<Nothing>()

/** Serialize the field with the given value. */
data class Value<T>(val v: T) : NullableField<T>()
}
13 changes: 10 additions & 3 deletions src/main/kotlin/com/nylas/models/UpdateEventRequest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ data class UpdateEventRequest(
* @see <a href="https://developers.google.com/calendar/api/v3/reference/colors">Google Calendar Colors</a>
*/
@Json(name = "color_id")
val colorId: String? = null,
val colorId: NullableField<String>? = null,
/**
* List of resources (e.g. rooms) to associate with the event.
*/
Expand Down Expand Up @@ -609,7 +609,7 @@ data class UpdateEventRequest(
private var capacity: Int? = null
private var hideParticipant: Boolean? = null
private var notetaker: EventNotetakerRequest? = null
private var colorId: String? = null
private var colorId: NullableField<String>? = null
private var resources: List<EventResource>? = null

/**
Expand Down Expand Up @@ -740,7 +740,14 @@ data class UpdateEventRequest(
* @return The builder.
* @see <a href="https://developers.google.com/calendar/api/v3/reference/colors">Google Calendar Colors</a>
*/
fun colorId(colorId: String) = apply { this.colorId = colorId }
fun colorId(colorId: String) = apply { this.colorId = NullableField.Value(colorId) }

/**
* Clear the Google color ID for the event, reverting to the calendar default.
* Sends `"color_id": null` in the request body.
* @return The builder.
*/
fun clearColorId() = apply { this.colorId = NullableField.Clear }

/**
* Update the list of resources (e.g. rooms) to associate with the event.
Expand Down
1 change: 1 addition & 0 deletions src/main/kotlin/com/nylas/util/JsonHelper.kt
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ class JsonHelper {
.add(MicrosoftAdminConsentCredentialDataAdapter())
.add(GoogleServiceAccountCredentialDataAdapter())
.add(ConnectorOverrideCredentialDataAdapter())
.add(NullableFieldAdapterFactory())
// Polymorphic adapters
.add(WHEN_JSON_FACTORY)
.add(FREE_BUSY_JSON_FACTORY)
Expand Down
57 changes: 57 additions & 0 deletions src/main/kotlin/com/nylas/util/NullableFieldAdapterFactory.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.nylas.util

import com.nylas.models.NullableField
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.JsonReader
import com.squareup.moshi.JsonWriter
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import java.lang.reflect.ParameterizedType
import java.lang.reflect.Type

/**
* Moshi adapter factory for [NullableField].
* - Kotlin null field → field omitted from JSON (handled by KotlinJsonAdapterFactory before us)
* - [NullableField.Clear] → `"field": null`
* - [NullableField.Value] → `"field": <value>`
* @suppress Not for public use.
*/
class NullableFieldAdapterFactory : JsonAdapter.Factory {
override fun create(type: Type, annotations: Set<Annotation>, moshi: Moshi): JsonAdapter<*>? {
if (Types.getRawType(type) != NullableField::class.java) return null
val innerType = (type as ParameterizedType).actualTypeArguments[0]

@Suppress("UNCHECKED_CAST")
val innerAdapter = moshi.adapter<Any>(innerType) as JsonAdapter<Any>
return NullableFieldAdapter(innerAdapter)
}

private class NullableFieldAdapter<T : Any>(
private val innerAdapter: JsonAdapter<T>,
) : JsonAdapter<NullableField<T>>() {
override fun toJson(writer: JsonWriter, value: NullableField<T>?) {
when (value) {
is NullableField.Clear -> {
// JsonWriter drops nullValue() silently when serializeNulls=false.
// Force it on just for this write so the explicit null reaches the wire.
val prev = writer.serializeNulls
writer.serializeNulls = true
writer.nullValue()
writer.serializeNulls = prev
}
is NullableField.Value -> innerAdapter.toJson(writer, value.v)
null -> writer.nullValue()
}
}

override fun fromJson(reader: JsonReader): NullableField<T>? {
return if (reader.peek() == JsonReader.Token.NULL) {
reader.nextNull<Any>()
NullableField.Clear
} else {
@Suppress("UNCHECKED_CAST")
NullableField.Value(innerAdapter.fromJson(reader) as T)
}
}
}
}
50 changes: 48 additions & 2 deletions src/test/kotlin/com/nylas/resources/EventsTests.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue

class EventsTests {
Expand Down Expand Up @@ -534,7 +535,7 @@ class EventsTests {
@Test
fun `UpdateEventRequest with colorId serializes color_id field`() {
val adapter = JsonHelper.moshi().adapter(UpdateEventRequest::class.java)
val request = UpdateEventRequest(colorId = "11")
val request = UpdateEventRequest(colorId = NullableField.Value("11"))

val jsonMap = JsonHelper.moshi().adapter(Map::class.java).fromJson(adapter.toJson(request))!!
assertEquals("11", jsonMap["color_id"])
Expand All @@ -549,6 +550,51 @@ class EventsTests {
assertFalse(json.contains("color_id"))
}

@Test
fun `UpdateEventRequest with clearColorId serializes color_id as null`() {
val adapter = JsonHelper.moshi().adapter(UpdateEventRequest::class.java)
val request = UpdateEventRequest.Builder().clearColorId().build()

val json = adapter.toJson(request)
val jsonMap = JsonHelper.moshi().adapter(Map::class.java).fromJson(json)!!
assertTrue(jsonMap.containsKey("color_id"))
assertNull(jsonMap["color_id"])
}

@Test
fun `UpdateEventRequest Builder colorId method serializes color_id field`() {
val adapter = JsonHelper.moshi().adapter(UpdateEventRequest::class.java)
val request = UpdateEventRequest.Builder().colorId("9").build()

val jsonMap = JsonHelper.moshi().adapter(Map::class.java).fromJson(adapter.toJson(request))!!
assertEquals("9", jsonMap["color_id"])
}

@Test
fun `UpdateEventRequest with color_id value in JSON deserializes to NullableField Value`() {
val adapter = JsonHelper.moshi().adapter(UpdateEventRequest::class.java)
val request = adapter.fromJson("""{"color_id":"5"}""")

assertIs<NullableField.Value<String>>(request?.colorId)
assertEquals("5", (request?.colorId as NullableField.Value).v)
}

@Test
fun `UpdateEventRequest with null color_id in JSON deserializes to NullableField Clear`() {
val adapter = JsonHelper.moshi().adapter(UpdateEventRequest::class.java)
val request = adapter.fromJson("""{"color_id":null}""")

assertIs<NullableField.Clear>(request?.colorId)
}

@Test
fun `UpdateEventRequest with colorId roundtrips through serialization correctly`() {
val adapter = JsonHelper.moshi().adapter(UpdateEventRequest::class.java)
val original = UpdateEventRequest.Builder().colorId("3").build()

assertEquals(original, adapter.fromJson(adapter.toJson(original)))
}

@Test
fun `Event with existing ConferencingProvider still works properly`() {
// This test verifies that the original Event model continues to work with the original ConferencingProvider enum
Expand Down Expand Up @@ -1055,7 +1101,7 @@ class EventsTests {
transcription = true,
),
),
colorId = "11",
colorId = NullableField.Value("11"),
)
val updateEventQueryParams =
UpdateEventQueryParams(
Expand Down
38 changes: 38 additions & 0 deletions src/test/kotlin/com/nylas/util/NullableFieldAdapterFactoryTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.nylas.util

import com.nylas.models.NullableField
import com.squareup.moshi.Types
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNull

class NullableFieldAdapterFactoryTest {
private val moshi = JsonHelper.moshi()
private val adapter = moshi.adapter<NullableField<String>>(
Types.newParameterizedType(NullableField::class.java, String::class.java),
)

@Test
fun `create returns null for non-NullableField type`() {
val result = NullableFieldAdapterFactory().create(String::class.java, emptySet(), moshi)
assertNull(result)
}

@Test
fun `toJson with null NullableField writes JSON null`() {
assertEquals("null", adapter.toJson(null))
}

@Test
fun `fromJson with JSON null returns NullableField Clear`() {
assertIs<NullableField.Clear>(adapter.fromJson("null"))
}

@Test
fun `fromJson with JSON string returns NullableField Value`() {
val result = adapter.fromJson("\"hello\"")
assertIs<NullableField.Value<String>>(result)
assertEquals("hello", result.v)
}
}
Loading