diff --git a/CHANGELOG.md b/CHANGELOG.md index 81e60c3..ffa24b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ Versions prior to v1.3.0 were maintained in a private repository (history unavil The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.8.0] - 2026-06-22 + +### Added + +- **Idempotency support** — mutation endpoints (POST, PUT, PATCH, DELETE) now accept an `idempotencyKey` via `RequestOptions`. The API deduplicates requests sharing the same key within a 24-hour window; subsequent retries with the same key replay the stored response without re-executing side effects. + - `RequestOptions.idempotencyKey?: string` — pass `crypto.randomUUID()` once per logical operation and reuse on every retry of that operation. + - `IDEMPOTENCY_KEY_HEADER` constant (`'Idempotency-Key'`) exported from the root package — use when inspecting outgoing request headers. + - `IDEMPOTENT_REPLAYED_HEADER` constant (`'Idempotent-Replayed'`) exported from the root package — check this response header to detect replayed responses. + - A 409 conflict (same key already in flight) surfaces as a `ContioAPIError` with `statusCode: 409`; the `retryAfter` field indicates when to retry. + - Idempotency keys are ignored on GET/HEAD requests (naturally idempotent) and multipart/file-upload endpoints. + ## [1.7.1] - 2026-05-21 ### Changed @@ -307,6 +318,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Error handling**: `ContioAPIError` with structured error information - **Retry logic**: Automatic retry with exponential backoff for transient failures +[1.8.0]: https://github.com/Contio-AI/partner-sdk/compare/v1.7.1...v1.8.0 +[1.7.1]: https://github.com/Contio-AI/partner-sdk/compare/v1.7.0...v1.7.1 +[1.7.0]: https://github.com/Contio-AI/partner-sdk/compare/v1.6.0...v1.7.0 [1.6.0]: https://github.com/Contio-AI/partner-sdk/compare/v1.5.0...v1.6.0 [1.5.0]: https://github.com/Contio-AI/partner-sdk/compare/v1.4.7...v1.5.0 [1.4.7]: https://github.com/Contio-AI/partner-sdk/compare/v1.4.6...v1.4.7 diff --git a/package.json b/package.json index 0b48174..4e6f0fb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@contio/partner-sdk", - "version": "1.7.1", + "version": "1.8.0", "description": "Official SDK for Contio MeetingOS Partner API", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/client/base.ts b/src/client/base.ts index a094771..66c41ad 100644 --- a/src/client/base.ts +++ b/src/client/base.ts @@ -10,7 +10,7 @@ import { ErrorResponse } from '../models/auth'; * SDK version for User-Agent header. * This is updated during the release process. */ -export const SDK_VERSION = '1.4.5'; +export const SDK_VERSION = '1.8.0'; export interface ClientConfig { baseURL?: string; @@ -34,6 +34,22 @@ export interface RequestOptions { * (e.g., 'America/New_York') or a timezone abbreviation (e.g., 'EST', 'PST'). */ timezone?: string; + /** + * Idempotency key for safe retries on mutation requests (POST, PUT, PATCH, DELETE). + * The API deduplicates requests sharing the same key within a 24-hour window — + * the first request executes normally and its response is stored; subsequent retries + * with the same key replay the stored response without re-executing. + * + * - Ignored on GET/HEAD requests (they are naturally idempotent). + * - Ignored on multipart/file-upload endpoints. + * - Must be ≤255 printable ASCII characters. + * - A conflict (another request with the same key still in flight) returns a 409 + * error whose {@link ContioAPIError.retryAfter} field indicates when to retry. + * + * Use `crypto.randomUUID()` to generate a unique key per logical operation and + * reuse that same key on every retry of that operation. + */ + idempotencyKey?: string; } /** @@ -41,6 +57,19 @@ export interface RequestOptions { */ export const TIMEZONE_HEADER = 'X-Client-Timezone'; +/** + * Header name for idempotency keys on mutation requests. + * @see {@link RequestOptions.idempotencyKey} + */ +export const IDEMPOTENCY_KEY_HEADER = 'Idempotency-Key'; + +/** + * Response header set to `"true"` by the API when the response body is a + * replay of a previously captured response (i.e. the idempotency key was + * already used and the original response is being returned verbatim). + */ +export const IDEMPOTENT_REPLAYED_HEADER = 'Idempotent-Replayed'; + export class ContioAPIError extends Error { public readonly code: string; public readonly statusCode?: number; @@ -123,7 +152,7 @@ export abstract class BaseClient { protected abstract addAuthHeaders(config: InternalAxiosRequestConfig): InternalAxiosRequestConfig | Promise; /** - * Build axios config with optional per-request timezone override + * Build axios config with optional per-request overrides (timezone, idempotency key). */ protected buildRequestConfig(options?: RequestOptions, baseConfig?: AxiosRequestConfig): AxiosRequestConfig { const config: AxiosRequestConfig = { ...baseConfig }; @@ -135,6 +164,13 @@ export abstract class BaseClient { }; } + if (options?.idempotencyKey) { + config.headers = { + ...config.headers, + [IDEMPOTENCY_KEY_HEADER]: options.idempotencyKey, + }; + } + return config; } diff --git a/src/index.ts b/src/index.ts index 838bffc..41afd5a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,7 +17,7 @@ export { ApiKeyClient } from './auth/apiKey'; // Export API clients export { PartnerUserClient } from './client/user'; export { PartnerAdminClient } from './client/admin'; -export { BaseClient, ContioAPIError, TIMEZONE_HEADER } from './client/base'; +export { BaseClient, ContioAPIError, TIMEZONE_HEADER, IDEMPOTENCY_KEY_HEADER, IDEMPOTENT_REPLAYED_HEADER } from './client/base'; export type { ClientConfig, RequestOptions } from './client/base'; // Export all models diff --git a/tests/base-client.test.ts b/tests/base-client.test.ts index 658bed9..50dbd59 100644 --- a/tests/base-client.test.ts +++ b/tests/base-client.test.ts @@ -6,7 +6,7 @@ import MockAdapter from 'axios-mock-adapter'; import { PartnerUserClient } from '../src/client/user'; import { OAuthClient } from '../src/auth/oauth'; -import { ContioAPIError } from '../src/client/base'; +import { ContioAPIError, IDEMPOTENCY_KEY_HEADER, IDEMPOTENT_REPLAYED_HEADER } from '../src/client/base'; describe('BaseClient', () => { let oauthClient: OAuthClient; @@ -414,6 +414,79 @@ describe('BaseClient', () => { }); }); + describe('Idempotency', () => { + it('should send Idempotency-Key header on POST requests', async () => { + mockAxios.onPost('/meetings').reply((config) => { + expect(config.headers?.[IDEMPOTENCY_KEY_HEADER]).toBe('test-idem-key'); + return [201, { id: 'm1', title: 'Test' }]; + }); + + await userClient.createMeeting( + { title: 'Test', start_time: '2026-06-22T10:00:00Z', end_time: '2026-06-22T11:00:00Z', workspace_id: 'ws1' }, + { idempotencyKey: 'test-idem-key' }, + ); + }); + + it('should send Idempotency-Key header on PATCH requests', async () => { + mockAxios.onPatch('/meetings/m1').reply((config) => { + expect(config.headers?.[IDEMPOTENCY_KEY_HEADER]).toBe('patch-key'); + return [200, { id: 'm1', title: 'Updated' }]; + }); + + await userClient.updateMeeting('m1', { title: 'Updated' }, { idempotencyKey: 'patch-key' }); + }); + + it('should send Idempotency-Key header on DELETE requests', async () => { + mockAxios.onDelete('/meetings/m1/participants/p1').reply((config) => { + expect(config.headers?.[IDEMPOTENCY_KEY_HEADER]).toBe('delete-key'); + return [204, null]; + }); + + await userClient.removeMeetingParticipant('m1', 'p1', { idempotencyKey: 'delete-key' }); + }); + + it('should not send Idempotency-Key header when idempotencyKey is not set', async () => { + mockAxios.onPost('/meetings').reply((config) => { + expect(config.headers?.[IDEMPOTENCY_KEY_HEADER]).toBeUndefined(); + return [201, { id: 'm1', title: 'Test' }]; + }); + + await userClient.createMeeting( + { title: 'Test', start_time: '2026-06-22T10:00:00Z', end_time: '2026-06-22T11:00:00Z', workspace_id: 'ws1' }, + ); + }); + + it('should surface retryAfter on 409 in-flight conflict', async () => { + const noRetryClient = new PartnerUserClient(oauthClient, { retries: 0 }); + const noRetryMock = new MockAdapter((noRetryClient as any).axiosInstance); + + noRetryMock.onPost('/meetings').reply( + 409, + { error: 'idempotency_conflict', code: 'idempotency_conflict', message: 'Request in progress' }, + { 'retry-after': '5' }, + ); + + try { + await noRetryClient.createMeeting( + { title: 'Test', start_time: '2026-06-22T10:00:00Z', end_time: '2026-06-22T11:00:00Z', workspace_id: 'ws1' }, + { idempotencyKey: 'conflict-key' }, + ); + fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(ContioAPIError); + const apiError = error as ContioAPIError; + expect(apiError.statusCode).toBe(409); + expect(apiError.retryAfter).toBe('5'); + } + noRetryMock.reset(); + }); + + it('should export IDEMPOTENCY_KEY_HEADER and IDEMPOTENT_REPLAYED_HEADER constants', () => { + expect(IDEMPOTENCY_KEY_HEADER).toBe('Idempotency-Key'); + expect(IDEMPOTENT_REPLAYED_HEADER).toBe('Idempotent-Replayed'); + }); + }); + describe('Request interceptor error path', () => { it('should reject with the error when request interceptor fails', async () => { const noRetryClient = new PartnerUserClient(oauthClient, { retries: 0 });