diff --git a/CHANGELOG.md b/CHANGELOG.md index 86082566..0753e02c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` diff --git a/src/main/kotlin/com/nylas/models/NullableField.kt b/src/main/kotlin/com/nylas/models/NullableField.kt new file mode 100644 index 00000000..23c44186 --- /dev/null +++ b/src/main/kotlin/com/nylas/models/NullableField.kt @@ -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 { + /** Serialize the field as JSON null, signalling the server to clear the value. */ + object Clear : NullableField() + + /** Serialize the field with the given value. */ + data class Value(val v: T) : NullableField() +} diff --git a/src/main/kotlin/com/nylas/models/UpdateEventRequest.kt b/src/main/kotlin/com/nylas/models/UpdateEventRequest.kt index 6c51e59f..3c26733d 100644 --- a/src/main/kotlin/com/nylas/models/UpdateEventRequest.kt +++ b/src/main/kotlin/com/nylas/models/UpdateEventRequest.kt @@ -100,7 +100,7 @@ data class UpdateEventRequest( * @see Google Calendar Colors */ @Json(name = "color_id") - val colorId: String? = null, + val colorId: NullableField? = null, /** * List of resources (e.g. rooms) to associate with the event. */ @@ -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? = null private var resources: List? = null /** @@ -740,7 +740,14 @@ data class UpdateEventRequest( * @return The builder. * @see Google Calendar Colors */ - 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. diff --git a/src/main/kotlin/com/nylas/util/JsonHelper.kt b/src/main/kotlin/com/nylas/util/JsonHelper.kt index 8db7f701..410391d2 100644 --- a/src/main/kotlin/com/nylas/util/JsonHelper.kt +++ b/src/main/kotlin/com/nylas/util/JsonHelper.kt @@ -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) diff --git a/src/main/kotlin/com/nylas/util/NullableFieldAdapterFactory.kt b/src/main/kotlin/com/nylas/util/NullableFieldAdapterFactory.kt new file mode 100644 index 00000000..86ca5cd7 --- /dev/null +++ b/src/main/kotlin/com/nylas/util/NullableFieldAdapterFactory.kt @@ -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": ` + * @suppress Not for public use. + */ +class NullableFieldAdapterFactory : JsonAdapter.Factory { + override fun create(type: Type, annotations: Set, 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(innerType) as JsonAdapter + return NullableFieldAdapter(innerAdapter) + } + + private class NullableFieldAdapter( + private val innerAdapter: JsonAdapter, + ) : JsonAdapter>() { + override fun toJson(writer: JsonWriter, value: NullableField?) { + 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? { + return if (reader.peek() == JsonReader.Token.NULL) { + reader.nextNull() + NullableField.Clear + } else { + @Suppress("UNCHECKED_CAST") + NullableField.Value(innerAdapter.fromJson(reader) as T) + } + } + } +} diff --git a/src/test/kotlin/com/nylas/resources/EventsTests.kt b/src/test/kotlin/com/nylas/resources/EventsTests.kt index 240f1d84..6aee2523 100644 --- a/src/test/kotlin/com/nylas/resources/EventsTests.kt +++ b/src/test/kotlin/com/nylas/resources/EventsTests.kt @@ -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 { @@ -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"]) @@ -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>(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(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 @@ -1055,7 +1101,7 @@ class EventsTests { transcription = true, ), ), - colorId = "11", + colorId = NullableField.Value("11"), ) val updateEventQueryParams = UpdateEventQueryParams( diff --git a/src/test/kotlin/com/nylas/util/NullableFieldAdapterFactoryTest.kt b/src/test/kotlin/com/nylas/util/NullableFieldAdapterFactoryTest.kt new file mode 100644 index 00000000..d0ac8bed --- /dev/null +++ b/src/test/kotlin/com/nylas/util/NullableFieldAdapterFactoryTest.kt @@ -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>( + 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(adapter.fromJson("null")) + } + + @Test + fun `fromJson with JSON string returns NullableField Value`() { + val result = adapter.fromJson("\"hello\"") + assertIs>(result) + assertEquals("hello", result.v) + } +}