-
Notifications
You must be signed in to change notification settings - Fork 471
feat(agiled): add agiled plugin and contacts endpoint #966
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a50f194
4be8e34
0a50b99
afe0704
d328e16
db9df3e
74b917d
e600505
9156d5f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| import type { | ||
| ApiRequestOptions, | ||
| OpenAPIConfig, | ||
| RateLimitConfig, | ||
| } from 'corsair/http'; | ||
| import { ApiError, request } from 'corsair/http'; | ||
|
|
||
| export class AgiledAPIError extends Error { | ||
| constructor( | ||
| message: string, | ||
| public readonly code?: string, | ||
| ) { | ||
| super(message); | ||
| this.name = 'AgiledAPIError'; | ||
| } | ||
| } | ||
|
|
||
| export const AGILED_API_BASE = 'https://app.agiled.app/api/public/v1'; | ||
|
|
||
| const READ_MAX_ATTEMPTS = 6; | ||
|
|
||
| const NO_RETRY: RateLimitConfig = { | ||
| enabled: true, | ||
| maxRetries: 0, | ||
| initialRetryDelay: 0, | ||
| backoffMultiplier: 1, | ||
| headerNames: { | ||
| retryAfter: 'retry-after', | ||
| }, | ||
| }; | ||
|
|
||
| function isRetryableAgiledError(error: unknown): error is ApiError { | ||
| if (!(error instanceof ApiError) || error.status === undefined) { | ||
| return false; | ||
| } | ||
| return error.status === 429 || error.status >= 500; | ||
| } | ||
|
|
||
| function retryDelayMs(error: ApiError, attempt: number): number { | ||
| if (typeof error.retryAfter === 'number' && error.retryAfter >= 0) { | ||
| return error.retryAfter; | ||
| } | ||
| return 2 ** attempt * 1000; | ||
| } | ||
|
|
||
| export async function makeAgiledRequest<T>( | ||
| endpoint: string, | ||
| apiKey: string, | ||
| options: { | ||
| method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; | ||
| body?: Record<string, unknown>; | ||
| query?: Record<string, string | number | boolean | undefined>; | ||
| retries?: boolean; | ||
| } = {}, | ||
| ): Promise<T> { | ||
| const { method = 'GET', body, query, retries = method === 'GET' } = options; | ||
|
|
||
| const config: OpenAPIConfig = { | ||
| BASE: AGILED_API_BASE, | ||
| VERSION: '1.0.0', | ||
| WITH_CREDENTIALS: false, | ||
| CREDENTIALS: 'omit', | ||
| TOKEN: apiKey, | ||
| HEADERS: { | ||
| 'Content-Type': 'application/json', | ||
| Accept: 'application/json', | ||
| }, | ||
|
greptile-apps[bot] marked this conversation as resolved.
|
||
| }; | ||
|
|
||
| const requestOptions: ApiRequestOptions = { | ||
| method, | ||
| url: endpoint, | ||
| body: | ||
| method === 'POST' || method === 'PUT' || method === 'PATCH' | ||
| ? body | ||
| : undefined, | ||
| mediaType: 'application/json; charset=utf-8', | ||
| query: method === 'GET' ? query : undefined, | ||
| }; | ||
|
|
||
| const send = async (): Promise<T> => { | ||
| try { | ||
| return await request<T>(config, requestOptions, { | ||
| rateLimitConfig: NO_RETRY, | ||
| }); | ||
| } catch (error) { | ||
| if (error instanceof ApiError) { | ||
| throw error; | ||
| } | ||
| if (error instanceof Error) { | ||
| throw new AgiledAPIError(error.message); | ||
| } | ||
| throw new AgiledAPIError('Unknown error'); | ||
| } | ||
| }; | ||
|
|
||
| if (!retries) { | ||
| return await send(); | ||
| } | ||
|
|
||
| let lastError: unknown; | ||
| for (let attempt = 0; attempt < READ_MAX_ATTEMPTS; attempt++) { | ||
| try { | ||
| return await send(); | ||
| } catch (error) { | ||
| lastError = error; | ||
| if (!isRetryableAgiledError(error) || attempt === READ_MAX_ATTEMPTS - 1) { | ||
| throw error; | ||
| } | ||
| await new Promise((resolve) => | ||
| setTimeout(resolve, retryDelayMs(error, attempt)), | ||
| ); | ||
| } | ||
| } | ||
| throw lastError; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,200 @@ | ||
| import { AuthMissingError } from 'corsair/core'; | ||
| import { ApiError, request } from 'corsair/http'; | ||
| import { makeAgiledRequest } from './client'; | ||
| import { errorHandlers } from './error-handlers'; | ||
| import type { AgiledContext } from './index'; | ||
| import { agiled, agiledEndpointSchemas } from './index'; | ||
|
|
||
| jest.mock('corsair/http', () => { | ||
| const original = jest.requireActual('corsair/http'); | ||
| return { | ||
| ...original, | ||
| request: jest.fn(), | ||
| }; | ||
| }); | ||
|
|
||
| const mockRequest = request as jest.Mock; | ||
|
|
||
| const mockCtx = { | ||
| key: 'agiled_test_key', | ||
| $getAccountId: () => 'test-account-id', | ||
| options: {}, | ||
| keys: { | ||
| get_api_key: jest.fn().mockResolvedValue('agiled_test_key'), | ||
| }, | ||
| logEvent: jest.fn(), | ||
| database: {}, | ||
| } as unknown as AgiledContext; | ||
|
|
||
| describe('Agiled plugin registry', () => { | ||
| const plugin = agiled(); | ||
| const endpoints = plugin.endpoints!; | ||
|
|
||
| it('registers contacts.list with schemas and metadata', () => { | ||
| expect(plugin.id).toBe('agiled'); | ||
| expect(endpoints.contacts.list).toBeDefined(); | ||
| expect(plugin.webhooks).toEqual({}); | ||
| expect(Object.keys(agiledEndpointSchemas)).toEqual(['contacts.list']); | ||
| expect(plugin.endpointMeta?.['contacts.list']?.riskLevel).toBe('read'); | ||
| }); | ||
|
|
||
| it('throws AuthMissingError when no API key is configured', async () => { | ||
| await expect( | ||
| plugin.keyBuilder!( | ||
| { | ||
| ...mockCtx, | ||
| authType: 'api_key', | ||
| keys: { | ||
| get_api_key: jest.fn().mockResolvedValue(undefined), | ||
| }, | ||
| } as unknown as Parameters<NonNullable<typeof plugin.keyBuilder>>[0], | ||
| 'endpoint', | ||
| ), | ||
| ).rejects.toBeInstanceOf(AuthMissingError); | ||
| }); | ||
|
|
||
| it('does not match incoming webhooks', () => { | ||
| expect( | ||
| plugin.pluginWebhookMatcher?.({ | ||
| headers: { 'x-agiled-signature': 'anything' }, | ||
| body: JSON.stringify({ type: 'example' }), | ||
| }), | ||
| ).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| describe('Agiled client error wrapping and retries', () => { | ||
| beforeEach(() => { | ||
| mockRequest.mockReset(); | ||
| }); | ||
|
|
||
| it('rethrows ApiError without dropping status and retry metadata', async () => { | ||
| const apiError = new ApiError( | ||
| { method: 'GET', url: 'https://app.agiled.app/api/public/v1/contacts' }, | ||
| { | ||
| ok: false, | ||
| status: 429, | ||
| statusText: 'Too Many Requests', | ||
| url: 'https://app.agiled.app/api/public/v1/contacts', | ||
| body: { message: 'Rate limit exceeded' }, | ||
| }, | ||
| 'Too Many Requests', | ||
| ); | ||
| mockRequest.mockRejectedValue(apiError); | ||
|
|
||
| await expect( | ||
| makeAgiledRequest('/contacts', 'test-key', { | ||
| method: 'GET', | ||
| retries: false, | ||
| }), | ||
| ).rejects.toThrow(apiError); | ||
| }); | ||
|
|
||
| it('retries GET 429s inside the client', async () => { | ||
| const apiError = new ApiError( | ||
| { method: 'GET', url: 'https://app.agiled.app/api/public/v1/contacts' }, | ||
| { | ||
| ok: false, | ||
| status: 429, | ||
| statusText: 'Too Many Requests', | ||
| url: 'https://app.agiled.app/api/public/v1/contacts', | ||
| body: { message: 'Rate limit exceeded' }, | ||
| }, | ||
| 'Too Many Requests', | ||
| { retryAfter: 0 }, | ||
| ); | ||
| mockRequest | ||
| .mockRejectedValueOnce(apiError) | ||
| .mockResolvedValueOnce({ data: [] }); | ||
|
|
||
| const result = await makeAgiledRequest('/contacts', 'test-key', { | ||
| method: 'GET', | ||
| }); | ||
| expect(result).toEqual({ data: [] }); | ||
| expect(mockRequest).toHaveBeenCalledTimes(2); | ||
| }); | ||
|
|
||
| it('does not retry POST requests', async () => { | ||
| const apiError = new ApiError( | ||
| { method: 'POST', url: 'https://app.agiled.app/api/public/v1/contacts' }, | ||
| { | ||
| ok: false, | ||
| status: 429, | ||
| statusText: 'Too Many Requests', | ||
| url: 'https://app.agiled.app/api/public/v1/contacts', | ||
| body: { message: 'Rate limit exceeded' }, | ||
| }, | ||
| 'Too Many Requests', | ||
| ); | ||
| mockRequest.mockRejectedValue(apiError); | ||
|
|
||
| await expect( | ||
| makeAgiledRequest('/contacts', 'test-key', { | ||
| method: 'POST', | ||
| body: { first_name: 'Ada' }, | ||
| }), | ||
| ).rejects.toThrow(apiError); | ||
| expect(mockRequest).toHaveBeenCalledTimes(1); | ||
| }); | ||
| }); | ||
|
|
||
| describe('Agiled binder error handlers', () => { | ||
| it('keeps 429 binder retries at zero', async () => { | ||
| const apiError = new ApiError( | ||
| { method: 'GET', url: 'https://app.agiled.app/api/public/v1/contacts' }, | ||
| { | ||
| ok: false, | ||
| status: 429, | ||
| statusText: 'Too Many Requests', | ||
| url: 'https://app.agiled.app/api/public/v1/contacts', | ||
| body: {}, | ||
| }, | ||
| 'Too Many Requests', | ||
| ); | ||
| expect(errorHandlers.RATE_LIMIT_ERROR.match(apiError)).toBe(true); | ||
| await expect( | ||
| errorHandlers.RATE_LIMIT_ERROR.handler(apiError), | ||
| ).resolves.toMatchObject({ maxRetries: 0 }); | ||
| }); | ||
| }); | ||
|
|
||
| describe('Agiled contacts.list', () => { | ||
| const endpoints = agiled().endpoints!; | ||
|
|
||
| beforeEach(() => { | ||
| mockRequest.mockReset(); | ||
| }); | ||
|
|
||
| it('GETs /contacts with page and limit', async () => { | ||
| mockRequest.mockResolvedValue({ | ||
| data: [{ id: 1, first_name: 'Ada', email: 'ada@example.com' }], | ||
| current_page: 2, | ||
| last_page: 4, | ||
| }); | ||
|
|
||
| const result = await endpoints.contacts.list(mockCtx, { | ||
| page: 2, | ||
| limit: 25, | ||
| }); | ||
|
|
||
| expect(mockRequest).toHaveBeenCalledWith( | ||
| expect.objectContaining({ | ||
| BASE: 'https://app.agiled.app/api/public/v1', | ||
| TOKEN: 'agiled_test_key', | ||
| HEADERS: expect.not.objectContaining({ | ||
| Authorization: 'Bearer ${apikey}', | ||
| }), | ||
| }), | ||
| expect.objectContaining({ | ||
| method: 'GET', | ||
| url: '/contacts', | ||
| query: { page: 2, limit: 25 }, | ||
| }), | ||
| expect.objectContaining({ | ||
| rateLimitConfig: expect.objectContaining({ maxRetries: 0 }), | ||
| }), | ||
| ); | ||
| expect(result.data).toHaveLength(1); | ||
| expect(result.current_page).toBe(2); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| import type { AgiledEndpoints } from '..'; | ||
| import { makeAgiledRequest } from '../client'; | ||
| import type { AgiledEndpointOutputs } from './types'; | ||
|
|
||
| export const list: AgiledEndpoints['listContacts'] = async (ctx, input) => { | ||
| return makeAgiledRequest<AgiledEndpointOutputs['listContacts']>( | ||
| '/contacts', | ||
| ctx.key, | ||
| { | ||
| method: 'GET', | ||
| query: { | ||
| page: input.page, | ||
| limit: input.limit, | ||
| }, | ||
| }, | ||
| ); | ||
|
Comment on lines
+6
to
+16
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Agiled returns a contact that violates Rule Used: Every endpoint must validate inputs and outputs wi... (source) Knowledge Base Used: Provider plugin implementation conventions |
||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| import { list } from './contacts'; | ||
|
|
||
| export const Contacts = { | ||
| list, | ||
| }; | ||
|
|
||
| export * from './types'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| import { z } from 'zod'; | ||
|
|
||
| const ContactSchema = z.object({ | ||
| id: z.number().or(z.string()), | ||
| first_name: z.string(), | ||
| last_name: z.string().optional(), | ||
| email: z.string().email().optional(), | ||
| phone: z.string().nullable().optional(), | ||
| }); | ||
|
|
||
| const ListContactsInputSchema = z.object({ | ||
| page: z.number().optional(), | ||
| limit: z.number().optional(), | ||
| }); | ||
|
|
||
| export type ListContactsInput = z.infer<typeof ListContactsInputSchema>; | ||
|
|
||
| const ListContactsResponseSchema = z.object({ | ||
| data: z.array(ContactSchema), | ||
| current_page: z.number().optional(), | ||
| last_page: z.number().optional(), | ||
| }); | ||
|
|
||
| export type ListContactsResponse = z.infer<typeof ListContactsResponseSchema>; | ||
|
|
||
| export type AgiledEndpointInputs = { | ||
| listContacts: ListContactsInput; | ||
| }; | ||
|
|
||
| export type AgiledEndpointOutputs = { | ||
| listContacts: ListContactsResponse; | ||
| }; | ||
|
|
||
| export const AgiledEndpointInputSchemas = { | ||
| listContacts: ListContactsInputSchema, | ||
| } as const; | ||
|
|
||
| export const AgiledEndpointOutputSchemas = { | ||
| listContacts: ListContactsResponseSchema, | ||
| } as const; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Prevent retries for write requests.
A caller can set
retries: trueforPOST,PUT,PATCH, orDELETE. A 5xx response can occur after Agiled applies the write. The next attempt can duplicate the mutation.Allow
retriesto disable GET retries only. Force retries off for every write method.Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents