-
Notifications
You must be signed in to change notification settings - Fork 372
feat(bunnycdn): add BunnyCDN plugin with PullZone endpoints #948
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
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 BunnycdnAPIError extends Error { | ||
| constructor( | ||
| message: string, | ||
| public readonly code?: string, | ||
| ) { | ||
| super(message); | ||
| this.name = 'BunnycdnAPIError'; | ||
| } | ||
| } | ||
|
|
||
| const BUNNYCDN_API_BASE = 'https://api.bunny.net'; | ||
|
|
||
| export async function makeBunnycdnRequest<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: BUNNYCDN_API_BASE, | ||
| VERSION: '1.0.0', | ||
| WITH_CREDENTIALS: false, | ||
| CREDENTIALS: 'omit', | ||
| TOKEN: apiKey, | ||
| HEADERS: { | ||
| 'Content-Type': 'application/json', | ||
| 'AccessKey': 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 && typeof error === 'object' && 'status' in error) { | ||
| throw error; | ||
| } | ||
| if (error instanceof Error) { | ||
| throw new BunnycdnAPIError(`BunnyCDN API Error: ${error.message}`); | ||
| } | ||
| throw new BunnycdnAPIError('Unknown error'); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| import { makeBunnycdnRequest } from '../client'; | ||
| import type { BunnycdnContext } from '../index'; | ||
| import type { | ||
| PullZone, | ||
| PullZoneGetInput, | ||
| PullZoneListInput, | ||
| } from './types'; | ||
|
|
||
| export const PullZoneEndpoints = { | ||
| list: async (ctx: BunnycdnContext, input: PullZoneListInput = {}): Promise<PullZone[]> => { | ||
| const key = (await ctx.keys?.get_api_key()) ?? ctx.options.key ?? ''; | ||
| return makeBunnycdnRequest<PullZone[]>('/pullzone', key, { | ||
| method: 'GET', | ||
| query: { | ||
| page: input.page, | ||
| perPage: input.perPage, | ||
| }, | ||
| }); | ||
|
Comment on lines
+10
to
+18
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. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift Model paginated list responses. If Return a paginated result type and schema when pagination is enabled, or remove 🤖 Prompt for AI Agents |
||
| }, | ||
|
|
||
| get: async (ctx: BunnycdnContext, input: PullZoneGetInput): Promise<PullZone> => { | ||
| const key = (await ctx.keys?.get_api_key()) ?? ctx.options.key ?? ''; | ||
| return makeBunnycdnRequest<PullZone>(`/pullzone/${input.id}`, key, { | ||
| method: 'GET', | ||
| }); | ||
| }, | ||
| }; | ||
|
|
||
| export * from './types'; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| import { z } from 'zod'; | ||
|
|
||
| const PullZoneListInputSchema = z.object({ | ||
| page: z.number().optional(), | ||
| perPage: z.number().optional(), | ||
| }); | ||
|
|
||
| const PullZoneGetInputSchema = z.object({ | ||
| id: z.number(), | ||
| }); | ||
|
|
||
| export type PullZoneListInput = z.infer<typeof PullZoneListInputSchema>; | ||
| export type PullZoneGetInput = z.infer<typeof PullZoneGetInputSchema>; | ||
|
|
||
| const PullZoneSchema = z.object({ | ||
| Id: z.number(), | ||
| Name: z.string(), | ||
| OriginUrl: z.string().optional(), | ||
| Enabled: z.boolean().optional(), | ||
| Hostnames: z.array(z.object({ | ||
| Id: z.number().optional(), | ||
| Value: z.string().optional(), | ||
| })).optional(), | ||
| }); | ||
|
Comment on lines
+15
to
+24
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. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win Correct the Pull Zone response contract and add focused endpoint coverage.
📍 Affects 2 files
🤖 Prompt for AI Agents |
||
|
|
||
| export type PullZone = z.infer<typeof PullZoneSchema>; | ||
|
|
||
| export type BunnycdnEndpointInputs = { | ||
| pullZoneList: PullZoneListInput; | ||
| pullZoneGet: PullZoneGetInput; | ||
| }; | ||
|
|
||
| export type BunnycdnEndpointOutputs = { | ||
| pullZoneList: PullZone[]; | ||
| pullZoneGet: PullZone; | ||
| }; | ||
|
|
||
| export const BunnycdnEndpointInputSchemas = { | ||
| pullZoneList: PullZoneListInputSchema, | ||
| pullZoneGet: PullZoneGetInputSchema, | ||
| } as const; | ||
|
|
||
| export const BunnycdnEndpointOutputSchemas = { | ||
| pullZoneList: z.array(PullZoneSchema), | ||
| pullZoneGet: PullZoneGetInputSchema, | ||
|
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 inspection or documentation tooling reads File Used: .github/PLUGIN_PR_RULES.md (source) Knowledge Base Used: Provider plugin implementation conventions Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! |
||
| } as const; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| import { ApiError } from 'corsair/http'; | ||
| import type { CorsairErrorHandler } from 'corsair/core'; | ||
|
|
||
| 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,202 @@ | ||
| import type { | ||
| BindEndpoints, | ||
| BindWebhooks, | ||
| CorsairEndpoint, | ||
| CorsairErrorHandler, | ||
| CorsairPlugin, | ||
| CorsairPluginContext, | ||
| CorsairWebhook, | ||
| KeyBuilderContext, | ||
| PickAuth, | ||
| PluginAuthConfig, | ||
| PluginPermissionsConfig, | ||
| RequiredPluginEndpointMeta, | ||
| RequiredPluginEndpointSchemas, | ||
| RequiredPluginWebhookSchemas, | ||
| } from 'corsair/core'; | ||
| import type { AuthTypes } from 'corsair/core'; | ||
| import type { BunnycdnEndpointInputs, BunnycdnEndpointOutputs } from './endpoints/types'; | ||
| import { BunnycdnEndpointInputSchemas, BunnycdnEndpointOutputSchemas } from './endpoints/types'; | ||
| import type { | ||
| BunnycdnWebhookOutputs, | ||
| ExampleEvent, | ||
| } from './webhooks/types'; | ||
| import { ExampleEventSchema } from './webhooks/types'; | ||
| import { PullZoneEndpoints } from './endpoints'; | ||
| import { BunnycdnSchema } from './schema'; | ||
| import { ExampleWebhooks } from './webhooks'; | ||
| import { errorHandlers } from './error-handlers'; | ||
| import { matchBunnycdnTenantWebhook } from './webhooks/tenant-matcher'; | ||
|
|
||
| export type BunnycdnPluginOptions = { | ||
| authType?: PickAuth<'api_key'>; | ||
| key?: string; | ||
| webhookSecret?: string; | ||
| hooks?: InternalBunnycdnPlugin['hooks']; | ||
| webhookHooks?: InternalBunnycdnPlugin['webhookHooks']; | ||
| errorHandlers?: CorsairErrorHandler; | ||
| permissions?: PluginPermissionsConfig<typeof bunnycdnEndpointsNested>; | ||
| }; | ||
|
|
||
| export type BunnycdnContext = CorsairPluginContext< | ||
| typeof BunnycdnSchema, | ||
| BunnycdnPluginOptions | ||
| >; | ||
|
|
||
| export type BunnycdnKeyBuilderContext = KeyBuilderContext<BunnycdnPluginOptions>; | ||
|
|
||
| export type BunnycdnBoundEndpoints = BindEndpoints<typeof bunnycdnEndpointsNested>; | ||
|
|
||
| type BunnycdnEndpoint< | ||
| K extends keyof BunnycdnEndpointOutputs, | ||
| > = CorsairEndpoint< | ||
| BunnycdnContext, | ||
| BunnycdnEndpointInputs[K], | ||
| BunnycdnEndpointOutputs[K] | ||
| >; | ||
|
|
||
| export type BunnycdnEndpoints = { | ||
| pullZoneList: BunnycdnEndpoint<'pullZoneList'>; | ||
| pullZoneGet: BunnycdnEndpoint<'pullZoneGet'>; | ||
| }; | ||
|
|
||
| type BunnycdnWebhook< | ||
| K extends keyof BunnycdnWebhookOutputs, | ||
| TEvent, | ||
| > = CorsairWebhook<BunnycdnContext, TEvent, BunnycdnWebhookOutputs[K]>; | ||
|
|
||
| export type BunnycdnWebhooks = { | ||
| example: BunnycdnWebhook<'example', ExampleEvent>; | ||
| }; | ||
|
|
||
| export type BunnycdnBoundWebhooks = BindWebhooks<BunnycdnWebhooks>; | ||
|
|
||
| const bunnycdnEndpointsNested = { | ||
| pullZone: { | ||
| list: PullZoneEndpoints.list, | ||
| get: PullZoneEndpoints.get, | ||
| }, | ||
| } as const; | ||
|
|
||
| const bunnycdnWebhooksNested = { | ||
| example: { | ||
| example: ExampleWebhooks.example, | ||
| }, | ||
| } as const; | ||
|
|
||
| export const bunnycdnEndpointSchemas = { | ||
| 'pullZone.list': { | ||
| input: BunnycdnEndpointInputSchemas.pullZoneList, | ||
| output: BunnycdnEndpointOutputSchemas.pullZoneList, | ||
| }, | ||
| 'pullZone.get': { | ||
| input: BunnycdnEndpointInputSchemas.pullZoneGet, | ||
| output: BunnycdnEndpointOutputSchemas.pullZoneGet, | ||
| }, | ||
| } as const satisfies RequiredPluginEndpointSchemas<typeof bunnycdnEndpointsNested>; | ||
|
|
||
| const bunnycdnWebhookSchemas = { | ||
| 'example.example': { | ||
| description: 'An example webhook event', | ||
| payload: ExampleEventSchema, | ||
| response: ExampleEventSchema, | ||
| }, | ||
| } as const satisfies RequiredPluginWebhookSchemas<typeof bunnycdnWebhooksNested>; | ||
|
|
||
| const defaultAuthType: AuthTypes = 'api_key' as const; | ||
|
|
||
| const bunnycdnEndpointMeta = { | ||
| 'pullZone.list': { | ||
| riskLevel: 'read', | ||
| description: 'Get list of all pull zones', | ||
| }, | ||
| 'pullZone.get': { | ||
| riskLevel: 'read', | ||
| description: 'Get details of a specific pull zone by ID', | ||
| }, | ||
| } as const satisfies RequiredPluginEndpointMeta<typeof bunnycdnEndpointsNested>; | ||
|
|
||
| export const bunnycdnAuthConfig = { | ||
| api_key: { | ||
| account: ['tenant_external_id'] as const, | ||
| }, | ||
| } as const satisfies PluginAuthConfig; | ||
|
|
||
| export type BaseBunnycdnPlugin<T extends BunnycdnPluginOptions> = CorsairPlugin< | ||
| 'bunnycdn', | ||
| typeof BunnycdnSchema, | ||
| typeof bunnycdnEndpointsNested, | ||
| typeof bunnycdnWebhooksNested, | ||
| T, | ||
| typeof defaultAuthType | ||
| >; | ||
|
|
||
| export type InternalBunnycdnPlugin = BaseBunnycdnPlugin<BunnycdnPluginOptions>; | ||
|
|
||
| export type ExternalBunnycdnPlugin<T extends BunnycdnPluginOptions> = | ||
| BaseBunnycdnPlugin<T>; | ||
|
|
||
| export function bunnycdn<const T extends BunnycdnPluginOptions>( | ||
| incomingOptions: BunnycdnPluginOptions & T = {} as BunnycdnPluginOptions & T, | ||
| ): ExternalBunnycdnPlugin<T> { | ||
| const options = { | ||
| ...incomingOptions, | ||
| authType: incomingOptions.authType ?? defaultAuthType, | ||
| }; | ||
| return { | ||
| id: 'bunnycdn', | ||
| authConfig: bunnycdnAuthConfig, | ||
| schema: BunnycdnSchema, | ||
| options: options, | ||
| hooks: options.hooks, | ||
| webhookHooks: options.webhookHooks, | ||
| endpoints: bunnycdnEndpointsNested, | ||
| webhooks: bunnycdnWebhooksNested, | ||
| endpointMeta: bunnycdnEndpointMeta, | ||
| endpointSchemas: bunnycdnEndpointSchemas, | ||
| webhookSchemas: bunnycdnWebhookSchemas, | ||
| pluginWebhookMatcher: (request) => { | ||
| const headers = request.headers; | ||
| return 'x-bunnycdn-signature' in headers; | ||
| }, | ||
| pluginTenantWebhookMatcher: matchBunnycdnTenantWebhook, | ||
| errorHandlers: { | ||
| ...errorHandlers, | ||
| ...options.errorHandlers, | ||
| }, | ||
| keyBuilder: async (ctx: BunnycdnKeyBuilderContext, 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 ?? ''; | ||
| } | ||
|
|
||
| return ''; | ||
| }, | ||
| } satisfies InternalBunnycdnPlugin; | ||
| } | ||
|
|
||
| export type { | ||
| ExampleEvent, | ||
| BunnycdnWebhookOutputs, | ||
| } from './webhooks/types'; | ||
|
|
||
| export type { | ||
| BunnycdnEndpointInputs, | ||
| BunnycdnEndpointOutputs, | ||
| PullZone, | ||
| PullZoneGetInput, | ||
| PullZoneListInput, | ||
| } from './endpoints/types'; |
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.
When BunnyCDN returns HTTP 429, this wrapper discards the
ApiErrorstatus and retry metadata; the resultingToo Many Requestsmessage matches neither rate-limit fallback, causing the request to fall through to the non-retrying default handler instead of honoringRetry-After.Rule Used: Every endpoint must validate inputs and outputs wi... (source)
Knowledge Base Used: