Skip to content

attendees (and similar fields) serialized as repeated query params, but API expects comma-delimited string — silently wrong, order-dependent results #337

Description

@ryansmartpadpro

Summary

ListEventQueryParams.attendees is typed as List<String> and serialized by NylasClient.addQueryParams as repeated query params (attendees=a&attendees=b). But the field's own docstring says the API expects a comma-delimited string, and the actual /v3/grants/{grant_id}/events endpoint documents attendees as type string, not an array. There is no array type for attendees in the API contract at all.

Because of this mismatch, calling the SDK with multiple attendees silently returns wrong results — with no error, exception, or warning.

Where

  • Field:
    /**
    * Filter for events that include the specified attendees.
    * This parameter accepts a comma-delimited list of email addresses.
    * (Not supported for virtual calendars)
    */
    @Json(name = "attendees")
    val attendees: List<String>? = null,
    /**
     * Filter for events that include the specified attendees.
     * This parameter accepts a comma-delimited list of email addresses.
     * (Not supported for virtual calendars)
     */
    @Json(name = "attendees")
    val attendees: List<String>? = null,
  • Serialization:
    }
    private fun addQueryParams(url: HttpUrl.Builder, params: Map<String, Any>): HttpUrl.Builder {
    for ((key, value) in params) {
    when (value) {
    is List<*> -> {
    for (item in value) {
    url.addQueryParameter(key, item.toString())
    }
    }
    is Map<*, *> -> {
    is List<*> -> {
      for (item in value) {
        url.addQueryParameter(key, item.toString())
      }
    }
    This emits one attendees= param per list item instead of joining them into the single comma-delimited string the docstring (and the API docs) describe.

Reproduction

Tested directly against /v3/grants/{grant_id}/events (bypassing the SDK, to isolate server vs. client behavior) with 3 events in range: one with only peter, one with rumit+peter, one with ale+rumit+peter.

Query string sent Events returned
attendees=ale@x.com&attendees=rumit@x.com 2 (matches rumit-only filter — last param wins)
attendees=rumit@x.com&attendees=ale@x.com (same values, order swapped) 1 (matches ale-only filter — last param wins, not order-independent)
attendees=ale@x.com,rumit@x.com (comma-joined, single param) 1 (event with both — comma-join is AND, not "any of")

So passing the SDK's attendees list currently: (a) silently drops every value except the last one, and (b) is order-dependent — the exact same logical filter returns a different result depending on the order items were added to the list, which is a pretty surprising property for a library caller to hit.

Suggested fix

Rather than fixing this one field at a time with a bespoke convertToMap() override per class (the pattern already used ad hoc for inFolder in ListThreadsQueryParams), it'd be worth adding a small piece of shared, declarative metadata to IQueryParams so every affected field opts in with one line, right next to its existing "comma-delimited" docstring:

interface IQueryParams {
  /**
   * Keys whose List<String> values should be serialized as a single
   * comma-joined query param instead of one repeated param per item.
   * Only needed for fields the API documents as a scalar comma-delimited
   * string (e.g. attendees) — NOT for fields that genuinely support
   * repeated params for OR semantics (e.g. event_type).
   */
  val commaDelimitedKeys: Set<String> get() = emptySet()

  fun convertToMap(): Map<String, Any> {
    val json = JsonHelper.moshi().adapter(this.javaClass).toJson(this)
    if (json.isEmpty()) return emptyMap()

    val map = JsonHelper.jsonMapAdapter.fromJson(json)!!.toMutableMap()
    for (key in commaDelimitedKeys) {
      (map[key] as? List<*>)?.let { map[key] = it.joinToString(",") }
    }
    return map
  }
}
// ListEventQueryParams.kt
override val commaDelimitedKeys = setOf("attendees")

This keeps the public field typed as List<String>? (good ergonomics — callers still just pass a list, no manual .joinToString(",") at every call site), doesn't touch addQueryParams at all, and replaces the existing inFolder-only special case in ListThreadsQueryParams with the same general mechanism. Any other field documented as a scalar comma-delimited string (to/cc/bcc/any_email on ListMessagesQueryParams/ListThreadsQueryParams) could opt in the same way.

Note: event_type on ListEventQueryParams should NOT be added to commaDelimitedKeys — its docs explicitly document repeated-param OR semantics with a worked example (event_type=default&event_type=outOfOffice → returns events that are default OR outOfOffice), so the current repeated-key serialization via addQueryParams's generic is List<*> -> branch is correct there. This issue is specifically about fields documented as a single comma-delimited string value, where that generic branch doesn't match what the field's own docs promise.

Impact

Any caller passing more than one value to attendees (or the affected Messages/Threads fields) gets a silently wrong, order-dependent result set with no indication anything went wrong — this seems worth a fix or at least a docstring correction + loud deprecation warning (as was done for inFolder) so callers aren't surprised.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions