-
Notifications
You must be signed in to change notification settings - Fork 453
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 3 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,61 @@ | ||
| import type { ApiRequestOptions, OpenAPIConfig } from 'corsair/http'; | ||
| import { request } from 'corsair/http'; | ||
|
|
||
| export class AgiledAPIError extends Error { | ||
| constructor( | ||
| message: string, | ||
| public readonly code?: string, | ||
| ) { | ||
| super(message); | ||
| this.name = 'AgiledAPIError'; | ||
| } | ||
| } | ||
|
|
||
| const AGILED_API_BASE = 'https://app.agiled.app/api/public/v1'; | ||
|
|
||
| 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>; | ||
| } = {}, | ||
| ): Promise<T> { | ||
| const { method = 'GET', body, query } = options; | ||
|
|
||
| const config: OpenAPIConfig = { | ||
| BASE: AGILED_API_BASE, | ||
| VERSION: '1.0.0', | ||
| WITH_CREDENTIALS: false, | ||
| CREDENTIALS: 'omit', | ||
| TOKEN: apiKey, | ||
| HEADERS: { | ||
| 'Content-Type': 'application/json', | ||
| Authorization: 'Bearer ${apikey}', | ||
| Accept: 'application/json', | ||
| // TODO: Add authentication headers | ||
| // 'Authorization': \`Bearer \${apiKey}\` | ||
| }, | ||
| }; | ||
|
|
||
| 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, | ||
| }; | ||
|
|
||
| try { | ||
| return await request<T>(config, requestOptions); | ||
| } catch (error) { | ||
| if (error instanceof Error) { | ||
| throw new AgiledAPIError(error.message); | ||
| } | ||
|
greptile-apps[bot] marked this conversation as resolved.
Outdated
|
||
| throw new AgiledAPIError('Unknown error'); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| import { makeAgiledRequest } from '../client'; | ||
| import type { AgiledContext } from '../index'; | ||
| import type { ListContactsInput, ListContactsResponse } from './types'; | ||
|
|
||
| export const Contacts = { | ||
| list: async ( | ||
| ctx: AgiledContext, | ||
| input: ListContactsInput, | ||
| ): Promise<ListContactsResponse> => { | ||
| const apiKey = await ctx.key; | ||
|
|
||
| return makeAgiledRequest<ListContactsResponse>('/contacts', apiKey, { | ||
| method: 'GET', | ||
| query: input as Record<string, string | number | boolean | undefined>, | ||
| }); | ||
| }, | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| export * from './contacts'; | ||
| 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; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| import type { CorsairErrorHandler } from 'corsair/core'; | ||
| import { ApiError } from 'corsair/http'; | ||
|
|
||
| export const errorHandlers = { | ||
| RATE_LIMIT_ERROR: { | ||
| match: (error: Error) => { | ||
| if (error instanceof ApiError && error.status === 429) return true; | ||
| const msg = error.message.toLowerCase(); | ||
| return msg.includes('rate_limited') || msg.includes('429'); | ||
| }, | ||
| handler: async (error: Error) => { | ||
| let retryAfterMs: number | undefined; | ||
| if (error instanceof ApiError && error.retryAfter !== undefined) { | ||
| retryAfterMs = error.retryAfter; | ||
| } | ||
| return { maxRetries: 5, headersRetryAfterMs: retryAfterMs }; | ||
| }, | ||
| }, | ||
| AUTH_ERROR: { | ||
| match: (error: Error) => { | ||
| if (error instanceof ApiError && error.status === 401) return true; | ||
| const msg = error.message.toLowerCase(); | ||
| return msg.includes('unauthorized') || msg.includes('invalid_auth'); | ||
| }, | ||
| handler: async () => ({ maxRetries: 0 }), | ||
| }, | ||
| DEFAULT: { | ||
| match: () => true, | ||
| handler: async () => ({ maxRetries: 0 }), | ||
| }, | ||
| } satisfies CorsairErrorHandler; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,204 @@ | ||
| import type { | ||
| AuthTypes, | ||
| BindEndpoints, | ||
| BindWebhooks, | ||
| CorsairEndpoint, | ||
| CorsairErrorHandler, | ||
| CorsairPlugin, | ||
| CorsairPluginContext, | ||
| CorsairWebhook, | ||
| KeyBuilderContext, | ||
| PickAuth, | ||
| PluginAuthConfig, | ||
| PluginPermissionsConfig, | ||
| RequiredPluginEndpointMeta, | ||
| RequiredPluginEndpointSchemas, | ||
| RequiredPluginWebhookSchemas, | ||
| } from 'corsair/core'; | ||
| import { Contacts } from './endpoints'; | ||
| import type { | ||
| AgiledEndpointInputs, | ||
| AgiledEndpointOutputs, | ||
| } from './endpoints/types'; | ||
| import { | ||
| AgiledEndpointInputSchemas, | ||
| AgiledEndpointOutputSchemas, | ||
| } from './endpoints/types'; | ||
| import { errorHandlers } from './error-handlers'; | ||
| import { AgiledSchema } from './schema'; | ||
| import { ExampleWebhooks } from './webhooks'; | ||
| import { resolveAgiledOAuthWebhookTenantLink } from './webhooks/oauth-tenant-link'; | ||
| import { matchAgiledTenantWebhook } from './webhooks/tenant-matcher'; | ||
| import type { AgiledWebhookOutputs, ExampleEvent } from './webhooks/types'; | ||
| import { ExampleEventSchema } from './webhooks/types'; | ||
|
|
||
| export type AgiledPluginOptions = { | ||
| authType?: PickAuth<'api_key' | 'oauth_2'>; | ||
| key?: string; | ||
| webhookSecret?: string; | ||
| hooks?: InternalAgiledPlugin['hooks']; | ||
| webhookHooks?: InternalAgiledPlugin['webhookHooks']; | ||
| errorHandlers?: CorsairErrorHandler; | ||
| permissions?: PluginPermissionsConfig<typeof agiledEndpointsNested>; | ||
| }; | ||
|
|
||
| export type AgiledContext = CorsairPluginContext< | ||
| typeof AgiledSchema, | ||
| AgiledPluginOptions | ||
| >; | ||
|
|
||
| export type AgiledKeyBuilderContext = KeyBuilderContext<AgiledPluginOptions>; | ||
|
|
||
| export type AgiledBoundEndpoints = BindEndpoints<typeof agiledEndpointsNested>; | ||
|
|
||
| type AgiledEndpoint<K extends keyof AgiledEndpointOutputs> = CorsairEndpoint< | ||
| AgiledContext, | ||
| AgiledEndpointInputs[K], | ||
| AgiledEndpointOutputs[K] | ||
| >; | ||
|
|
||
| export type AgiledEndpoints = { | ||
| listContacts: AgiledEndpoint<'listContacts'>; | ||
| }; | ||
|
|
||
| type AgiledWebhook< | ||
| K extends keyof AgiledWebhookOutputs, | ||
| TEvent, | ||
| > = CorsairWebhook<AgiledContext, TEvent, AgiledWebhookOutputs[K]>; | ||
|
|
||
| export type AgiledWebhooks = { | ||
| example: AgiledWebhook<'example', ExampleEvent>; | ||
| }; | ||
|
|
||
| export type AgiledBoundWebhooks = BindWebhooks<AgiledWebhooks>; | ||
|
|
||
| const agiledEndpointsNested = { | ||
| contacts: { | ||
| list: Contacts.list, | ||
| }, | ||
| } as const; | ||
|
|
||
| const agiledWebhooksNested = { | ||
| example: { | ||
| example: ExampleWebhooks.example, | ||
| }, | ||
| } as const; | ||
|
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. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift Register the required Agiled webhook events. This tree registers only Replace the example webhook with typed handlers for the required Agiled events before registering webhooks. 🤖 Prompt for AI Agents |
||
|
|
||
| export const agiledEndpointSchemas = { | ||
| 'contacts.list': { | ||
| input: AgiledEndpointInputSchemas.listContacts, | ||
| output: AgiledEndpointOutputSchemas.listContacts, | ||
| }, | ||
| } as const satisfies RequiredPluginEndpointSchemas< | ||
| typeof agiledEndpointsNested | ||
| >; | ||
|
|
||
| const agiledWebhookSchemas = { | ||
| 'example.example': { | ||
| description: 'An example webhook event', | ||
| payload: ExampleEventSchema, | ||
| response: ExampleEventSchema, | ||
| }, | ||
| } as const satisfies RequiredPluginWebhookSchemas<typeof agiledWebhooksNested>; | ||
|
|
||
| const defaultAuthType: AuthTypes = 'api_key' as const; | ||
|
|
||
| const agiledEndpointMeta = { | ||
| 'contacts.list': { | ||
| riskLevel: 'read', | ||
| description: 'Get an list of contacts from agiled ', | ||
| }, | ||
| } as const satisfies RequiredPluginEndpointMeta<typeof agiledEndpointsNested>; | ||
|
|
||
| export const agiledAuthConfig = { | ||
| api_key: { | ||
| account: ['tenant_external_id'] as const, | ||
| }, | ||
| oauth_2: { | ||
| account: ['tenant_external_id'] as const, | ||
| }, | ||
| } as const satisfies PluginAuthConfig; | ||
|
|
||
| export type BaseAgiledPlugin<T extends AgiledPluginOptions> = CorsairPlugin< | ||
| 'agiled', | ||
| typeof AgiledSchema, | ||
| typeof agiledEndpointsNested, | ||
| typeof agiledWebhooksNested, | ||
| T, | ||
| typeof defaultAuthType | ||
| >; | ||
|
|
||
| export type InternalAgiledPlugin = BaseAgiledPlugin<AgiledPluginOptions>; | ||
|
|
||
| export type ExternalAgiledPlugin<T extends AgiledPluginOptions> = | ||
| BaseAgiledPlugin<T>; | ||
|
|
||
| export function agiled<const T extends AgiledPluginOptions>( | ||
| incomingOptions: AgiledPluginOptions & T = {} as AgiledPluginOptions & T, | ||
| ): ExternalAgiledPlugin<T> { | ||
| const options = { | ||
| ...incomingOptions, | ||
| authType: incomingOptions.authType ?? defaultAuthType, | ||
| }; | ||
| return { | ||
| id: 'agiled', | ||
| authConfig: agiledAuthConfig, | ||
| schema: AgiledSchema, | ||
| options: options, | ||
| hooks: options.hooks, | ||
| webhookHooks: options.webhookHooks, | ||
| endpoints: agiledEndpointsNested, | ||
| webhooks: agiledWebhooksNested, | ||
| endpointMeta: agiledEndpointMeta, | ||
| endpointSchemas: agiledEndpointSchemas, | ||
| webhookSchemas: agiledWebhookSchemas, | ||
| pluginWebhookMatcher: (request) => { | ||
| const headers = request.headers; | ||
| // TODO: Update to match your webhook signature headers | ||
| return 'x-agiled-signature' in headers; | ||
| }, | ||
| pluginTenantWebhookMatcher: matchAgiledTenantWebhook, | ||
| oauthWebhookTenantLinkResolver: resolveAgiledOAuthWebhookTenantLink, | ||
| errorHandlers: { | ||
| ...errorHandlers, | ||
| ...options.errorHandlers, | ||
| }, | ||
| keyBuilder: async (ctx: AgiledKeyBuilderContext, source) => { | ||
| if (source === 'webhook' && options.webhookSecret) { | ||
| return options.webhookSecret; | ||
| } | ||
|
|
||
| if (source === 'webhook') { | ||
| const res = await ctx.keys.get_webhook_signature(); | ||
| return res ?? ''; | ||
| } | ||
|
|
||
| if (source === 'endpoint' && options.key) { | ||
| return options.key; | ||
| } | ||
|
|
||
| if (source === 'endpoint' && ctx.authType === 'api_key') { | ||
| const res = await ctx.keys.get_api_key(); | ||
| return res ?? ''; | ||
| } | ||
|
|
||
| if (source === 'endpoint' && ctx.authType === 'oauth_2') { | ||
| const res = await ctx.keys.get_access_token(); | ||
| return res ?? ''; | ||
| } | ||
|
|
||
| return ''; | ||
| }, | ||
| } satisfies InternalAgiledPlugin; | ||
| } | ||
|
|
||
| export type { | ||
| AgiledEndpointInputs, | ||
| AgiledEndpointOutputs, | ||
| ListContactsInput, | ||
| ListContactsResponse, | ||
| } from './endpoints/types'; | ||
| export type { | ||
| AgiledWebhookOutputs, | ||
| ExampleEvent, | ||
| } from './webhooks/types'; | ||
Uh oh!
There was an error while loading. Please reload this page.