From 7eea44df1f58508cc5a6287356a05ded6ed60922 Mon Sep 17 00:00:00 2001 From: Gordan Ovcaric Date: Mon, 19 Jan 2026 23:25:36 +0100 Subject: [PATCH 1/4] first try --- .claude/instructions.md | 101 +++++++++ .claude/settings.local.json | 16 ++ CHANGELOG.md | 10 + build.gradle.kts | 3 +- settings.gradle.kts | 3 + src/main/kotlin/com/nylas/NylasClient.kt | 16 +- .../com/nylas/models/CreateEventRequest.kt | 11 +- .../kotlin/com/nylas/models/NylasApiError.kt | 35 +++- .../com/nylas/models/UpdateEventRequest.kt | 15 +- src/test/kotlin/com/nylas/NylasClientTest.kt | 178 +++++++++++++++- .../kotlin/com/nylas/resources/EventsTests.kt | 193 ++++++++++++++++++ 11 files changed, 573 insertions(+), 8 deletions(-) create mode 100644 .claude/instructions.md create mode 100644 .claude/settings.local.json diff --git a/.claude/instructions.md b/.claude/instructions.md new file mode 100644 index 00000000..6eebb70c --- /dev/null +++ b/.claude/instructions.md @@ -0,0 +1,101 @@ +# SDK Maintenance Instructions + +## Your Role +You are an expert at Kotlin and Java and maintain the Nylas Kotlin/Java SDK which is an interface for Nylas' APIs. + +Your job is to maintain parity with the API to ensure that the SDK supports all that the API supports. + +## Task Workflow + +When asked to add support for a new feature, query parameter, or API endpoint, follow this workflow: + +### 1. Understand the Codebase +- Scan the project structure +- Understand the architecture and how it's organized +- Identify existing patterns and conventions +- Review similar existing implementations + +### 2. Write Tests First (TDD) +- Implement tests for the new feature BEFORE writing implementation code +- Ensure backward compatibility is maintained +- **Never modify existing tests unless absolutely necessary** +- **IF BACKWARDS COMPATIBILITY CANNOT BE ACHIEVED, STOP AND INFORM THE USER** + +### 3. Implement the Solution +- Follow existing code patterns and conventions +- Maintain consistency with the rest of the codebase +- Keep changes minimal and focused +- Only modify production code in `src/main/` +- Only modify test code in `src/test/` + +### 4. Update the CHANGELOG +- Update `CHANGELOG.md` following conventional commits format +- Always add changes under an "Unreleased" version section +- Keep updates concise - one line per change +- Never include code examples in the changelog +- Format: `* Brief description of the change` + +### 5. Create Examples (if applicable) +- If the feature is user-facing, create an example in `examples/` +- Follow the pattern of existing examples +- Provide both Java and Kotlin examples when relevant + +### 6. Run Linters and Fix Formatting +- Run: `./gradlew formatKotlin` +- Run: `./gradlew lintKotlin` +- Fix any formatting or linting errors + +### 7. Verify Tests Pass +- Run: `./gradlew test` +- Ensure all tests pass before proceeding + +### 8. Git Commit +- Follow conventional commits format +- Format: `type(scope): description` +- Types: `feat`, `fix`, `docs`, `test`, `refactor`, `chore` +- Examples: + - `feat(folders): add include_hidden_folders query parameter` + - `fix(messages): correct attachment encoding for files < 3MB` + - `test(calendars): add coverage for event recurrence` + +## Important Notes + +- **Production Code Safety**: The SDK is live in production. Never break existing functionality. +- **Test Coverage**: Aim to maintain or improve test coverage. Current target is 99% by Q3. +- **Backward Compatibility**: This is critical. If you can't maintain it, stop and inform the user. +- **Java Version**: Project uses Java 11 (toolchain set to Java 8 for compatibility) +- **JAVA_HOME**: `/Users/gordan.o@nylas.com/Library/Java/JavaVirtualMachines/temurin-11.0.29/Contents/Home` + +## Code Patterns + +### Query Parameters +Query parameters are defined in `src/main/kotlin/com/nylas/models/*QueryParams.kt` files using: +- Data classes with nullable properties +- Builder pattern for construction +- `@Json(name = "...")` annotations for JSON serialization + +### Resource Methods +Resource classes in `src/main/kotlin/com/nylas/resources/*.kt` follow this pattern: +- Inherit from `Resource(client)` +- Use HTTP methods: `list()`, `find()`, `create()`, `update()`, `destroy()` +- Accept query params as optional parameters +- Return typed responses: `Response`, `ListResponse`, `DeleteResponse` + +### Tests +Test files in `src/test/kotlin/com/nylas/resources/*Tests.kt` use: +- Mockito for HTTP mocking +- JUnit 5 for test framework +- Nested test classes for organization +- Pattern: mock HTTP client → execute method → verify request → assert response + +## Quality Checklist + +Before considering a task complete: +- [ ] Tests written and passing +- [ ] Implementation follows existing patterns +- [ ] CHANGELOG.md updated +- [ ] Examples created (if applicable) +- [ ] Linters pass +- [ ] All tests pass +- [ ] Backward compatibility maintained +- [ ] Git commit created with conventional commit message diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..01d9d051 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,16 @@ +{ + "permissions": { + "allow": [ + "Bash(export JAVA_HOME=/Users/gordan.o@nylas.com/Library/Java/JavaVirtualMachines/corretto-11.0.29/Contents/Home:*)", + "Bash(./gradlew test:*)", + "Bash(export JAVA_HOME:*)", + "Bash(./gradlew jacocoTestReport:*)", + "Bash(open build/reports/jacoco/test/html/index.html)", + "Bash(cat:*)", + "Bash(find:*)", + "Bash(./gradlew clean build:*)", + "Bash(java -version:*)", + "Bash(/usr/libexec/java_home:*)" + ] + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 58a14017..910880fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Nylas Java SDK Changelog +## [Unreleased] + +### Added +* Client-side validation for `CreateEventRequest.When.Timespan` and `UpdateEventRequest.When.Timespan` to ensure `endTime` is after `startTime`, preventing confusing API errors +* `validationErrors` field to `NylasApiError` to capture field-level validation errors from the API +* Enhanced error messages in `NylasApiError.toString()` to display validation errors, provider errors, and request IDs for easier debugging + +### Fixed +* Improved error handling in `NylasClient` to provide more descriptive error messages when API responses cannot be parsed, including response body preview and status codes + ## [2.14.0] ### Added diff --git a/build.gradle.kts b/build.gradle.kts index c44fed2d..404f6a9a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,8 +15,6 @@ repositories { } java { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 withJavadocJar() withSourcesJar() } @@ -48,6 +46,7 @@ dependencies { testImplementation("org.junit.jupiter:junit-jupiter:5.10.0") testImplementation("org.mockito:mockito-inline:4.11.0") testImplementation("org.mockito.kotlin:mockito-kotlin:4.1.0") + implementation(kotlin("stdlib-jdk8")) } tasks.jacocoTestReport { diff --git a/settings.gradle.kts b/settings.gradle.kts index 5102999f..6f1526d3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,3 +1,6 @@ +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" +} /* * This file was generated by the Gradle 'init' task. * diff --git a/src/main/kotlin/com/nylas/NylasClient.kt b/src/main/kotlin/com/nylas/NylasClient.kt index 6a97af96..fa864645 100644 --- a/src/main/kotlin/com/nylas/NylasClient.kt +++ b/src/main/kotlin/com/nylas/NylasClient.kt @@ -433,9 +433,15 @@ open class NylasClient( } catch (ex: Exception) { when (ex) { is IOException, is JsonDataException -> { + // Attempt to extract useful error information even from malformed responses + val errorMessage = if (responseBody.isNotBlank()) { + "API request failed with status ${response.code}. Response body: ${responseBody.take(500)}" + } else { + "API request failed with status ${response.code} and empty response body" + } throw NylasApiError( type = "unknown", - message = "Unknown error received from the API: $responseBody", + message = errorMessage, statusCode = response.code, headers = response.headers.toMultimap(), ) @@ -451,9 +457,15 @@ open class NylasClient( throw parsedError } + // Final fallback if parsing succeeded but returned null + val errorMessage = if (responseBody.isNotBlank()) { + "API request failed with status ${response.code}. Response body: ${responseBody.take(500)}" + } else { + "API request failed with status ${response.code} and empty response body" + } throw NylasApiError( type = "unknown", - message = "Unknown error received from the API: $responseBody", + message = errorMessage, statusCode = response.code, headers = response.headers.toMultimap(), ) diff --git a/src/main/kotlin/com/nylas/models/CreateEventRequest.kt b/src/main/kotlin/com/nylas/models/CreateEventRequest.kt index 22675655..eac50393 100644 --- a/src/main/kotlin/com/nylas/models/CreateEventRequest.kt +++ b/src/main/kotlin/com/nylas/models/CreateEventRequest.kt @@ -235,8 +235,17 @@ data class CreateEventRequest( /** * Builds the [Timespan] object. * @return [Timespan] object. + * @throws IllegalArgumentException if endTime is not after startTime. */ - fun build() = Timespan(startTime, endTime, startTimezone, endTimezone) + fun build(): Timespan { + // Validate that endTime must be after startTime + require(endTime > startTime) { + "Invalid Timespan: endTime ($endTime) must be after startTime ($startTime). " + + "Timespan events require a positive duration. " + + "For point-in-time events, use CreateEventRequest.When.Time instead." + } + return Timespan(startTime, endTime, startTimezone, endTimezone) + } } } } diff --git a/src/main/kotlin/com/nylas/models/NylasApiError.kt b/src/main/kotlin/com/nylas/models/NylasApiError.kt index fd38faaa..10539590 100644 --- a/src/main/kotlin/com/nylas/models/NylasApiError.kt +++ b/src/main/kotlin/com/nylas/models/NylasApiError.kt @@ -21,6 +21,12 @@ data class NylasApiError( */ @Json(name = "provider_error") val providerError: Map? = null, + /** + * Field-level validation errors from the API. + * Maps field names to their specific error messages. + */ + @Json(name = "validation_errors") + val validationErrors: Map? = null, /** * The HTTP status code of the error response */ @@ -33,4 +39,31 @@ data class NylasApiError( * The HTTP headers of the error response */ override var headers: Map>? = null, -) : AbstractNylasApiError(message, statusCode, requestId, headers) +) : AbstractNylasApiError(message, statusCode, requestId, headers) { + + override fun toString(): String { + val sb = StringBuilder() + sb.append("NylasApiError: $message") + + if (statusCode != null) { + sb.append(" (HTTP $statusCode)") + } + + if (!validationErrors.isNullOrEmpty()) { + sb.append("\nValidation errors:") + validationErrors.forEach { (field, error) -> + sb.append("\n - $field: $error") + } + } + + if (providerError != null) { + sb.append("\nProvider error: $providerError") + } + + if (requestId != null) { + sb.append("\nRequest ID: $requestId") + } + + return sb.toString() + } +} diff --git a/src/main/kotlin/com/nylas/models/UpdateEventRequest.kt b/src/main/kotlin/com/nylas/models/UpdateEventRequest.kt index 98961616..531a6844 100644 --- a/src/main/kotlin/com/nylas/models/UpdateEventRequest.kt +++ b/src/main/kotlin/com/nylas/models/UpdateEventRequest.kt @@ -277,8 +277,21 @@ data class UpdateEventRequest( /** * Builds the [Timespan] object. * @return [Timespan] object. + * @throws IllegalArgumentException if both startTime and endTime are provided and endTime is not after startTime. */ - fun build() = Timespan(startTime, endTime, startTimezone, endTimezone) + fun build(): Timespan { + // Validate that if both times are set, endTime must be after startTime + val start = startTime + val end = endTime + if (start != null && end != null) { + require(end > start) { + "Invalid Timespan: endTime ($end) must be after startTime ($start). " + + "Timespan events require a positive duration. " + + "For point-in-time events, use UpdateEventRequest.When.Time instead." + } + } + return Timespan(startTime, endTime, startTimezone, endTimezone) + } } } } diff --git a/src/test/kotlin/com/nylas/NylasClientTest.kt b/src/test/kotlin/com/nylas/NylasClientTest.kt index 65ad3846..33d729df 100644 --- a/src/test/kotlin/com/nylas/NylasClientTest.kt +++ b/src/test/kotlin/com/nylas/NylasClientTest.kt @@ -317,7 +317,8 @@ class NylasClientTest { } assertEquals("unknown", exception.type) - assertEquals("Unknown error received from the API: not a json", exception.message) + assert(exception.message.contains("API request failed with status 400")) + assert(exception.message.contains("not a json")) assertEquals(400, exception.statusCode) assertEquals(headersMultiMap, exception.headers) } @@ -559,4 +560,179 @@ class NylasClientTest { assertEquals("Content-Type", NylasClient.HttpHeaders.CONTENT_TYPE.headerName) } } + + @Nested + inner class ErrorParsingTests { + private val mockHttpClient: OkHttpClient = mock(OkHttpClient::class.java) + private val mockCall: Call = mock(Call::class.java) + private val mockResponseBody: ResponseBody = mock(ResponseBody::class.java) + private val mockOkHttpClientBuilder: OkHttpClient.Builder = mock() + + @BeforeEach + fun setup() { + MockitoAnnotations.openMocks(this) + whenever(mockOkHttpClientBuilder.addInterceptor(any())).thenReturn(mockOkHttpClientBuilder) + whenever(mockOkHttpClientBuilder.build()).thenReturn(mockHttpClient) + whenever(mockHttpClient.newCall(any())).thenReturn(mockCall) + } + + @Test + fun `API 400 error with validation details is parsed correctly`() { + val errorJson = """ + { + "error": { + "type": "invalid_request", + "message": "Event validation failed", + "validation_errors": { + "when.end_time": "must be after start_time" + } + }, + "request_id": "req_abc123" + } + """.trimIndent() + + val mockResponse = mock(okhttp3.Response::class.java) + val mockBody = mock(ResponseBody::class.java) + + whenever(mockResponse.isSuccessful).thenReturn(false) + whenever(mockResponse.code).thenReturn(400) + whenever(mockResponse.body).thenReturn(mockBody) + whenever(mockBody.string()).thenReturn(errorJson) + whenever(mockResponse.headers).thenReturn(Headers.headersOf()) + whenever(mockCall.execute()).thenReturn(mockResponse) + + val client = NylasClient("test-api-key", mockOkHttpClientBuilder) + + val exception = assertFailsWith { + client.grants().list() + } + + assertEquals("invalid_request", exception.type) + assertEquals("Event validation failed", exception.message) + assertEquals(mapOf("when.end_time" to "must be after start_time"), exception.validationErrors) + assertEquals("req_abc123", exception.requestId) + assertEquals(400, exception.statusCode) + } + + @Test + fun `API 400 error without validation details is parsed correctly`() { + val errorJson = """ + { + "error": { + "type": "invalid_request", + "message": "Bad request" + }, + "request_id": "req_xyz789" + } + """.trimIndent() + + val mockResponse = mock(okhttp3.Response::class.java) + val mockBody = mock(ResponseBody::class.java) + + whenever(mockResponse.isSuccessful).thenReturn(false) + whenever(mockResponse.code).thenReturn(400) + whenever(mockResponse.body).thenReturn(mockBody) + whenever(mockBody.string()).thenReturn(errorJson) + whenever(mockResponse.headers).thenReturn(Headers.headersOf()) + whenever(mockCall.execute()).thenReturn(mockResponse) + + val client = NylasClient("test-api-key", mockOkHttpClientBuilder) + + val exception = assertFailsWith { + client.grants().list() + } + + assertEquals("invalid_request", exception.type) + assertEquals("Bad request", exception.message) + assertEquals(null, exception.validationErrors) + assertEquals("req_xyz789", exception.requestId) + } + + @Test + fun `Malformed API error response returns helpful fallback message`() { + val malformedJson = """{"invalid": "json structure""" + + val mockResponse = mock(okhttp3.Response::class.java) + val mockBody = mock(ResponseBody::class.java) + + whenever(mockResponse.isSuccessful).thenReturn(false) + whenever(mockResponse.code).thenReturn(400) + whenever(mockResponse.body).thenReturn(mockBody) + whenever(mockBody.string()).thenReturn(malformedJson) + whenever(mockResponse.headers).thenReturn(Headers.headersOf()) + whenever(mockCall.execute()).thenReturn(mockResponse) + + val client = NylasClient("test-api-key", mockOkHttpClientBuilder) + + val exception = assertFailsWith { + client.grants().list() + } + + assertEquals("unknown", exception.type) + assert(exception.message.contains("API request failed with status 400")) + assert(exception.message.contains("Response body:")) + assertEquals(400, exception.statusCode) + } + + @Test + fun `Empty response body returns helpful fallback message`() { + val mockResponse = mock(okhttp3.Response::class.java) + val mockBody = mock(ResponseBody::class.java) + + whenever(mockResponse.isSuccessful).thenReturn(false) + whenever(mockResponse.code).thenReturn(500) + whenever(mockResponse.body).thenReturn(mockBody) + whenever(mockBody.string()).thenReturn("") + whenever(mockResponse.headers).thenReturn(Headers.headersOf()) + whenever(mockCall.execute()).thenReturn(mockResponse) + + val client = NylasClient("test-api-key", mockOkHttpClientBuilder) + + val exception = assertFailsWith { + client.grants().list() + } + + assertEquals("unknown", exception.type) + assert(exception.message.contains("API request failed with status 500 and empty response body")) + assertEquals(500, exception.statusCode) + } + + @Test + fun `API error with provider error is parsed correctly`() { + val errorJson = """ + { + "error": { + "type": "provider_error", + "message": "Provider returned an error", + "provider_error": { + "error": "invalid_grant", + "error_description": "Token has been revoked" + } + }, + "request_id": "req_provider123" + } + """.trimIndent() + + val mockResponse = mock(okhttp3.Response::class.java) + val mockBody = mock(ResponseBody::class.java) + + whenever(mockResponse.isSuccessful).thenReturn(false) + whenever(mockResponse.code).thenReturn(400) + whenever(mockResponse.body).thenReturn(mockBody) + whenever(mockBody.string()).thenReturn(errorJson) + whenever(mockResponse.headers).thenReturn(Headers.headersOf()) + whenever(mockCall.execute()).thenReturn(mockResponse) + + val client = NylasClient("test-api-key", mockOkHttpClientBuilder) + + val exception = assertFailsWith { + client.grants().list() + } + + assertEquals("provider_error", exception.type) + assertEquals("Provider returned an error", exception.message) + assertNotNull(exception.providerError) + assertEquals("invalid_grant", exception.providerError!!["error"]) + } + } } diff --git a/src/test/kotlin/com/nylas/resources/EventsTests.kt b/src/test/kotlin/com/nylas/resources/EventsTests.kt index d343fed6..5dbfcd7a 100644 --- a/src/test/kotlin/com/nylas/resources/EventsTests.kt +++ b/src/test/kotlin/com/nylas/resources/EventsTests.kt @@ -496,6 +496,199 @@ class EventsTests { } } + @Nested + inner class ValidationTests { + @Test + fun `UpdateEventRequest Timespan with valid duration succeeds`() { + val timespan = UpdateEventRequest.When.Timespan.Builder() + .startTime(1620000000) + .endTime(1620003600) + .build() + + assertEquals(1620000000, timespan.startTime) + assertEquals(1620003600, timespan.endTime) + } + + @Test + fun `UpdateEventRequest Timespan with zero duration throws exception`() { + val exception = org.junit.jupiter.api.assertThrows { + UpdateEventRequest.When.Timespan.Builder() + .startTime(1620000000) + .endTime(1620000000) + .build() + } + + assert(exception.message!!.contains("endTime (1620000000) must be after startTime (1620000000)")) + assert(exception.message!!.contains("For point-in-time events, use UpdateEventRequest.When.Time instead")) + } + + @Test + fun `UpdateEventRequest Timespan with negative duration throws exception`() { + val exception = org.junit.jupiter.api.assertThrows { + UpdateEventRequest.When.Timespan.Builder() + .startTime(1620003600) + .endTime(1620000000) + .build() + } + + assert(exception.message!!.contains("endTime (1620000000) must be after startTime (1620003600)")) + assert(exception.message!!.contains("Timespan events require a positive duration")) + } + + @Test + fun `UpdateEventRequest Timespan with null endTime allows creation`() { + val timespan = UpdateEventRequest.When.Timespan.Builder() + .startTime(1620000000) + .build() + + assertEquals(1620000000, timespan.startTime) + assertEquals(null, timespan.endTime) + } + + @Test + fun `UpdateEventRequest Timespan with null startTime allows creation`() { + val timespan = UpdateEventRequest.When.Timespan.Builder() + .endTime(1620003600) + .build() + + assertEquals(null, timespan.startTime) + assertEquals(1620003600, timespan.endTime) + } + + @Test + fun `UpdateEventRequest Timespan with both null times allows creation`() { + val timespan = UpdateEventRequest.When.Timespan.Builder() + .build() + + assertEquals(null, timespan.startTime) + assertEquals(null, timespan.endTime) + } + + @Test + fun `UpdateEventRequest Timespan with one second duration succeeds`() { + val timespan = UpdateEventRequest.When.Timespan.Builder() + .startTime(1620000000) + .endTime(1620000001) + .build() + + assertEquals(1620000000, timespan.startTime) + assertEquals(1620000001, timespan.endTime) + } + + @Test + fun `CreateEventRequest Timespan with valid duration succeeds`() { + val timespan = CreateEventRequest.When.Timespan.Builder(1620000000, 1620003600) + .build() + + assertEquals(1620000000, timespan.startTime) + assertEquals(1620003600, timespan.endTime) + } + + @Test + fun `CreateEventRequest Timespan with zero duration throws exception`() { + val exception = org.junit.jupiter.api.assertThrows { + CreateEventRequest.When.Timespan.Builder(1620000000, 1620000000) + .build() + } + + assert(exception.message!!.contains("endTime (1620000000) must be after startTime (1620000000)")) + assert(exception.message!!.contains("For point-in-time events, use CreateEventRequest.When.Time instead")) + } + + @Test + fun `CreateEventRequest Timespan with negative duration throws exception`() { + val exception = org.junit.jupiter.api.assertThrows { + CreateEventRequest.When.Timespan.Builder(1620003600, 1620000000) + .build() + } + + assert(exception.message!!.contains("endTime (1620000000) must be after startTime (1620003600)")) + assert(exception.message!!.contains("Timespan events require a positive duration")) + } + + @Test + fun `CreateEventRequest Timespan with one second duration succeeds`() { + val timespan = CreateEventRequest.When.Timespan.Builder(1620000000, 1620000001) + .build() + + assertEquals(1620000000, timespan.startTime) + assertEquals(1620000001, timespan.endTime) + } + + @Test + fun `UpdateEventRequest Timespan validation does not affect direct constructor`() { + // Direct constructor should still work for backward compatibility + val timespan = UpdateEventRequest.When.Timespan(1620000000, 1620000000) + + assertEquals(1620000000, timespan.startTime) + assertEquals(1620000000, timespan.endTime) + } + + @Test + fun `CreateEventRequest Timespan validation does not affect direct constructor`() { + // Direct constructor should still work for backward compatibility + val timespan = CreateEventRequest.When.Timespan(1620000000, 1620000000) + + assertEquals(1620000000, timespan.startTime) + assertEquals(1620000000, timespan.endTime) + } + + @Test + fun `NylasApiError with validation errors formats toString correctly`() { + val error = NylasApiError( + type = "invalid_request", + message = "Event validation failed", + validationErrors = mapOf( + "when.end_time" to "must be after start_time", + "title" to "is required", + ), + statusCode = 400, + requestId = "req_12345", + ) + + val errorString = error.toString() + assert(errorString.contains("Event validation failed")) + assert(errorString.contains("HTTP 400")) + assert(errorString.contains("Validation errors:")) + assert(errorString.contains("when.end_time: must be after start_time")) + assert(errorString.contains("title: is required")) + assert(errorString.contains("Request ID: req_12345")) + } + + @Test + fun `NylasApiError without validation errors formats toString correctly`() { + val error = NylasApiError( + type = "invalid_request", + message = "Bad request", + statusCode = 400, + ) + + val errorString = error.toString() + assert(errorString.contains("Bad request")) + assert(errorString.contains("HTTP 400")) + assert(!errorString.contains("Validation errors:")) + } + + @Test + fun `NylasApiError serializes and deserializes with validation errors`() { + val adapter = JsonHelper.moshi().adapter(NylasApiError::class.java) + val json = """ + { + "type": "invalid_request", + "message": "Validation failed", + "validation_errors": { + "when.end_time": "must be after start_time" + } + } + """.trimIndent() + + val error = adapter.fromJson(json)!! + assertEquals("invalid_request", error.type) + assertEquals("Validation failed", error.message) + assertEquals(mapOf("when.end_time" to "must be after start_time"), error.validationErrors) + } + } + @Nested inner class CrudTests { private lateinit var grantId: String From b84b4d1d24b29d5d96424f37c7bdefd750010dcf Mon Sep 17 00:00:00 2001 From: Gordan Ovcaric Date: Wed, 21 Jan 2026 17:32:51 +0100 Subject: [PATCH 2/4] comments for better readability --- src/main/kotlin/com/nylas/models/NylasApiError.kt | 11 +++++++++++ .../kotlin/com/nylas/models/UpdateEventRequest.kt | 1 + 2 files changed, 12 insertions(+) diff --git a/src/main/kotlin/com/nylas/models/NylasApiError.kt b/src/main/kotlin/com/nylas/models/NylasApiError.kt index 10539590..2555c237 100644 --- a/src/main/kotlin/com/nylas/models/NylasApiError.kt +++ b/src/main/kotlin/com/nylas/models/NylasApiError.kt @@ -41,6 +41,17 @@ data class NylasApiError( override var headers: Map>? = null, ) : AbstractNylasApiError(message, statusCode, requestId, headers) { + /** + * Formats the error as a human-readable string. + * + * Example output: + * ``` + * NylasApiError: Bad Request (HTTP 400) + * Validation errors: + * - when.end_time: must be after start_time + * Request ID: abc-123 + * ``` + */ override fun toString(): String { val sb = StringBuilder() sb.append("NylasApiError: $message") diff --git a/src/main/kotlin/com/nylas/models/UpdateEventRequest.kt b/src/main/kotlin/com/nylas/models/UpdateEventRequest.kt index 531a6844..a97f3bed 100644 --- a/src/main/kotlin/com/nylas/models/UpdateEventRequest.kt +++ b/src/main/kotlin/com/nylas/models/UpdateEventRequest.kt @@ -281,6 +281,7 @@ data class UpdateEventRequest( */ fun build(): Timespan { // Validate that if both times are set, endTime must be after startTime + // Local variables for smart-cast null safety val start = startTime val end = endTime if (start != null && end != null) { From 43a23b1011699dbe37c774da442c5e5b3eda96d5 Mon Sep 17 00:00:00 2001 From: Gordan Ovcaric Date: Wed, 21 Jan 2026 17:45:28 +0100 Subject: [PATCH 3/4] test: add coverage for null error object fallback path Add test case for the scenario where API returns valid JSON but the error object parses to null, triggering the final fallback error message path in NylasClient. This improves test coverage for the enhanced error handling introduced in the previous commit. Co-Authored-By: Claude Sonnet 4.5 --- src/test/kotlin/com/nylas/NylasClientTest.kt | 27 ++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/test/kotlin/com/nylas/NylasClientTest.kt b/src/test/kotlin/com/nylas/NylasClientTest.kt index 33d729df..c9131de1 100644 --- a/src/test/kotlin/com/nylas/NylasClientTest.kt +++ b/src/test/kotlin/com/nylas/NylasClientTest.kt @@ -734,5 +734,32 @@ class NylasClientTest { assertNotNull(exception.providerError) assertEquals("invalid_grant", exception.providerError!!["error"]) } + + @Test + fun `API error with valid JSON but null error object returns helpful fallback message`() { + // This tests the final fallback path when JSON parsing succeeds but returns null + val validJsonButNullError = """{"some_field": "some_value"}""" + + val mockResponse = mock(okhttp3.Response::class.java) + val mockBody = mock(ResponseBody::class.java) + + whenever(mockResponse.isSuccessful).thenReturn(false) + whenever(mockResponse.code).thenReturn(503) + whenever(mockResponse.body).thenReturn(mockBody) + whenever(mockBody.string()).thenReturn(validJsonButNullError) + whenever(mockResponse.headers).thenReturn(Headers.headersOf()) + whenever(mockCall.execute()).thenReturn(mockResponse) + + val client = NylasClient("test-api-key", mockOkHttpClientBuilder) + + val exception = assertFailsWith { + client.grants().list() + } + + assertEquals("unknown", exception.type) + assert(exception.message.contains("API request failed with status 503")) + assert(exception.message.contains("Response body:")) + assertEquals(503, exception.statusCode) + } } } From 4b07505329c587e46558bb1cfdc34dcbf67fec32 Mon Sep 17 00:00:00 2001 From: Gordan Ovcaric Date: Wed, 21 Jan 2026 17:55:24 +0100 Subject: [PATCH 4/4] address gradle.kts comments --- build.gradle.kts | 2 +- settings.gradle.kts | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 404f6a9a..73799bd0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,6 +15,7 @@ repositories { } java { + sourceCompatibility = JavaVersion.VERSION_1_8 withJavadocJar() withSourcesJar() } @@ -46,7 +47,6 @@ dependencies { testImplementation("org.junit.jupiter:junit-jupiter:5.10.0") testImplementation("org.mockito:mockito-inline:4.11.0") testImplementation("org.mockito.kotlin:mockito-kotlin:4.1.0") - implementation(kotlin("stdlib-jdk8")) } tasks.jacocoTestReport { diff --git a/settings.gradle.kts b/settings.gradle.kts index 6f1526d3..5102999f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,6 +1,3 @@ -plugins { - id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" -} /* * This file was generated by the Gradle 'init' task. *