Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/corsair/core/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ export const BaseProviders = [
'customerio',
'customgpt',
'dadataru',
'daffy',
'databricks',
'datadog',
'datarobot',
Expand Down Expand Up @@ -506,6 +507,7 @@ export const ProviderDisplayNames = {
customerio: 'Customer.io',
customgpt: 'CustomGPT',
dadataru: 'Dadataru',
daffy: 'Daffy',
databricks: 'Databricks',
datadog: 'Datadog',
datarobot: 'DataRobot',
Expand Down Expand Up @@ -861,6 +863,7 @@ export type AllProviders =
| 'customerio'
| 'customgpt'
| 'dadataru'
| 'daffy'
| 'databricks'
| 'datadog'
| 'datarobot'
Expand Down
42 changes: 42 additions & 0 deletions packages/daffy/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# @corsair-dev/daffy

Daffy plugin for Corsair.

## Install

```bash
pnpm add @corsair-dev/daffy
```

## Endpoints

| Operation | Operation ID | Risk | Description |
|-----------|--------------|------|-------------|
| `accounts.getBalance` | `daffy.api.accounts.getBalance` | `read` | Retrieve the authenticated user's fund balance. |
| `accounts.getContributions` | `daffy.api.accounts.getContributions` | `read` | List contributions made to the authenticated fund. |
| `accounts.getDonations` | `daffy.api.accounts.getDonations` | `read` | List donations made by the authenticated user. |
| `accounts.getUserCauses` | `daffy.api.accounts.getUserCauses` | `read` | Retrieve a user's supported charitable causes. |
| `accounts.getUserDonations` | `daffy.api.accounts.getUserDonations` | `read` | Retrieve a user's public donations. |
| `accounts.getUserProfile` | `daffy.api.accounts.getUserProfile` | `read` | Retrieve the authenticated user's profile. |
| `accounts.getUserByUsername` | `daffy.api.accounts.getUserByUsername` | `read` | Retrieve a public user profile by username. |
| `gifts.create` | `daffy.api.gifts.create` | `write` | Create a Daffy charitable gift for a beneficiary. |
| `gifts.getByCode` | `daffy.api.gifts.getByCode` | `read` | Retrieve a gift by its unique code. |
| `gifts.list` | `daffy.api.gifts.list` | `read` | List gifts associated with the authenticated user. |
| `nonProfits.getByEin` | `daffy.api.nonProfits.getByEin` | `read` | Retrieve a nonprofit organization by EIN. |
| `nonProfits.search` | `daffy.api.nonProfits.search` | `read` | Search nonprofit organizations by cause or text. |

## Auth

Auth: API key. Corsair prompts your tenant for credentials on first use.

## Webhooks

No webhooks.

## Reference

Full docs, types, and examples: https://docs.corsair.dev/plugins/daffy

## License

Apache-2.0
102 changes: 102 additions & 0 deletions packages/daffy/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Live API tests for the Daffy plugin (per .github/PLUGIN_PR_RULES.md R4,
// real third-party proof lives outside CI).
//
// Run with: DAFFY_API_KEY=daffy_production_xxx pnpm test api.test.ts
// Without DAFFY_API_KEY every test below is skipped — CI never touches the
// real API. Only idempotent GET reads are exercised here; POST/DELETE writes
// (gifts.create, accounts.createDonation, accounts.cancelDonation) are covered
// by endpoints.test.ts against a mocked transport and are never fired live.
import { makeDaffyRequest } from './client';
import { DaffyEndpointOutputSchemas } from './endpoints/types';

const TEST_KEY: string = process.env.DAFFY_API_KEY ?? '';
const describeLive = TEST_KEY === '' ? describe.skip : describe;

describeLive('Daffy live API', () => {
it('accounts.getBalance returns the four documented metrics', async () => {
const raw = await makeDaffyRequest('/users/me/balance', TEST_KEY);
const balance = DaffyEndpointOutputSchemas.getBalance.parse(raw);
expect(balance.amount).toEqual(expect.any(Number));
expect(balance.available_balance).toEqual(expect.any(Number));
});

it('accounts.getUserProfile returns the authenticated profile', async () => {
const raw = await makeDaffyRequest('/users/me', TEST_KEY);
const profile = DaffyEndpointOutputSchemas.getUserProfile.parse(raw);
expect(profile.id).toEqual(expect.any(Number));

const causesRaw = await makeDaffyRequest(
`/users/${profile.id}/causes`,
TEST_KEY,
);
const causes = DaffyEndpointOutputSchemas.getUserCauses.parse(causesRaw);
expect(Array.isArray(causes)).toBe(true);
});

it('accounts.getContributions returns paginated contributions', async () => {
const raw = await makeDaffyRequest('/contributions', TEST_KEY, {
query: { page: 1 },
});
const page = DaffyEndpointOutputSchemas.getContributions.parse(raw);
expect(page.meta.page).toBe(1);
});

it('accounts.getDonations returns paginated own donations', async () => {
const raw = await makeDaffyRequest('/donations', TEST_KEY, {
query: { page: 1 },
});
const page = DaffyEndpointOutputSchemas.getDonations.parse(raw);
expect(page.meta.page).toBe(1);
});

it('accounts.getDonation reads a single user donation when one exists', async () => {
const profileRaw = await makeDaffyRequest('/users/me', TEST_KEY);
const profile = DaffyEndpointOutputSchemas.getUserProfile.parse(profileRaw);
const listRaw = await makeDaffyRequest(
`/users/${profile.id}/donations`,
TEST_KEY,
{ query: { page: 1 } },
);
const list = DaffyEndpointOutputSchemas.getUserDonations.parse(listRaw);
const first = list.items[0];
if (first === undefined) {
expect(list.items).toEqual([]);
return;
}
const singleRaw = await makeDaffyRequest(
`/users/${profile.id}/donations/${first.id}`,
TEST_KEY,
);
const single = DaffyEndpointOutputSchemas.getDonation.parse(singleRaw);
expect(single.id).toBe(first.id);
});

it('gifts.list and gifts.getByCode round-trip when a gift exists', async () => {
const listRaw = await makeDaffyRequest('/gifts', TEST_KEY, {
query: { page: 1 },
});
const list = DaffyEndpointOutputSchemas.getGifts.parse(listRaw);
const first = list.items[0];
if (first === undefined) {
expect(list.items).toEqual([]);
return;
}
const singleRaw = await makeDaffyRequest(`/gifts/${first.code}`, TEST_KEY);
const single = DaffyEndpointOutputSchemas.getGiftByCode.parse(singleRaw);
expect(single.code).toBe(first.code);
});

it('nonProfits.getByEin returns the documented nonprofit', async () => {
const raw = await makeDaffyRequest('/non_profits/261544963', TEST_KEY);
const nonprofit = DaffyEndpointOutputSchemas.getNonProfitByEin2.parse(raw);
expect(nonprofit.ein).toBe('261544963');
});

it('nonProfits.search returns paginated matches', async () => {
const raw = await makeDaffyRequest('/non_profits', TEST_KEY, {
query: { cause_id: 1, query: 'education', page: 1 },
});
const page = DaffyEndpointOutputSchemas.searchNonProfits.parse(raw);
expect(page.meta.page).toBe(1);
});
});
161 changes: 161 additions & 0 deletions packages/daffy/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import * as http from 'corsair/http';
import { makeDaffyRequest } from './client';

const TEST_KEY = 'test-key-not-a-credential';
let calls: { url: string; init: RequestInit }[] = [];
const realFetch = global.fetch;

function mockFetch(): void {
// No type assertions: serve a real Response so the stub already satisfies
// fetch's type instead of casting a partial object.
const stub: typeof global.fetch = async (input, init) => {
calls.push({ url: String(input), init: init ?? {} });
return new Response('{}', {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
};
global.fetch = stub;
}

beforeEach(() => {
calls = [];
mockFetch();
});
afterEach(() => {
global.fetch = realFetch;
});

describe('Daffy client', () => {
it.each([
[
'/users/me/balance',
undefined,
'https://public.daffy.org/v1/users/me/balance',
],
[
'/contributions',
{ page: 2 },
'https://public.daffy.org/v1/contributions?page=2',
],
['/donations', { page: 3 }, 'https://public.daffy.org/v1/donations?page=3'],
['/gifts', { page: 4 }, 'https://public.daffy.org/v1/gifts?page=4'],
[
'/gifts/gift-code',
undefined,
'https://public.daffy.org/v1/gifts/gift-code',
],
[
'/non_profits/261544963',
undefined,
'https://public.daffy.org/v1/non_profits/261544963',
],
[
'/non_profits',
{ cause_id: 1, query: 'education' },
'https://public.daffy.org/v1/non_profits?cause_id=1&query=education',
],
[
'/users/1/causes',
undefined,
'https://public.daffy.org/v1/users/1/causes',
],
[
'/users/1/donations',
{ page: 2 },
'https://public.daffy.org/v1/users/1/donations?page=2',
],
[
'/users/1/donations/2',
undefined,
'https://public.daffy.org/v1/users/1/donations/2',
],
[
'/donations/1234',
undefined,
'https://public.daffy.org/v1/donations/1234',
],
['/users/me', undefined, 'https://public.daffy.org/v1/users/me'],
[
'/users/api-user',
undefined,
'https://public.daffy.org/v1/users/api-user',
],
] as const)(
'routes %s through the public API',
async (path, query, expected) => {
await makeDaffyRequest(path, TEST_KEY, { query });
expect(calls[0]?.url).toBe(expected);
expect(new Headers(calls[0]?.init.headers).get('X-Api-Key')).toBe(
TEST_KEY,
);
},
);

it('disables transport retries for donation creation POSTs', async () => {
const requestSpy = jest.spyOn(http, 'request').mockResolvedValue({});
await makeDaffyRequest('/donations', TEST_KEY, {
method: 'POST',
body: { amount: 25, ein: '941156258', note: 'love what they do!' },
});
expect(requestSpy).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ method: 'POST', url: '/donations' }),
expect.objectContaining({
rateLimitConfig: expect.objectContaining({
enabled: false,
maxRetries: 0,
}),
}),
);
requestSpy.mockRestore();
});

it('disables transport retries for donation cancel DELETEs', async () => {
const requestSpy = jest.spyOn(http, 'request').mockResolvedValue({});
await makeDaffyRequest('/donations/1234', TEST_KEY, {
method: 'DELETE',
});
expect(requestSpy).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ method: 'DELETE', url: '/donations/1234' }),
expect.objectContaining({
rateLimitConfig: expect.objectContaining({
enabled: false,
maxRetries: 0,
}),
}),
);
requestSpy.mockRestore();
});

it('disables transport retries for gift creation POSTs', async () => {
const requestSpy = jest.spyOn(http, 'request').mockResolvedValue({});
await makeDaffyRequest('/gifts', TEST_KEY, {
method: 'POST',
body: { name: 'Jamie', amount: 18 },
});
expect(requestSpy).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ method: 'POST', url: '/gifts' }),
expect.objectContaining({
rateLimitConfig: expect.objectContaining({
enabled: false,
maxRetries: 0,
}),
}),
);
requestSpy.mockRestore();
});

it('sends gift creation as a JSON POST without putting the key in the URL', async () => {
await makeDaffyRequest('/gifts', TEST_KEY, {
method: 'POST',
body: { name: 'Jamie', amount: 18 },
});
const call = calls[0];
expect(call?.init.method).toBe('POST');
expect(call?.init.body).toBe(JSON.stringify({ name: 'Jamie', amount: 18 }));
expect(call?.url).not.toContain(TEST_KEY);
});
});
60 changes: 60 additions & 0 deletions packages/daffy/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type {
ApiRequestOptions,
OpenAPIConfig,
RateLimitConfig,
} from 'corsair/http';
import { request } from 'corsair/http';

const DAFFY_API_BASE = 'https://public.daffy.org/v1';

const DAFFY_RATE_LIMIT_CONFIG: RateLimitConfig = {
enabled: true,
maxRetries: 3,
initialRetryDelay: 1000,
backoffMultiplier: 2,
headerNames: {
retryAfter: 'Retry-After',
},
};

export type DaffyRequestOptions = {
method?: 'GET' | 'POST' | 'DELETE';
// unknown: bodies are endpoint-specific JSON (amounts, EINs, notes) built
// from zod-validated inputs at each call site, so per-key values vary.
body?: Record<string, unknown>;
query?: Record<string, string | number | undefined>;
};

export async function makeDaffyRequest<T>(
endpoint: string,
apiKey: string,
options: DaffyRequestOptions = {},
): Promise<T> {
const { method = 'GET', body, query } = options;

const config: OpenAPIConfig = {
BASE: DAFFY_API_BASE,
VERSION: '1.0.0',
WITH_CREDENTIALS: false,
CREDENTIALS: 'omit',
HEADERS: { 'X-Api-Key': apiKey },
};

const requestOptions: ApiRequestOptions = {
method,
url: endpoint,
body: method === 'POST' ? body : undefined,
mediaType: method === 'POST' ? 'application/json' : undefined,
query,
};
// No transport retries on state-changing requests: a 429 POST/DELETE is
// surfaced immediately so the plugin error policy is the only policy that
// ever sees it (replaying a create could duplicate it; error semantics for
// cancel are undocumented). Reads keep the default retries — GETs are idempotent.
return await request<T>(config, requestOptions, {
rateLimitConfig:
method === 'GET'
? DAFFY_RATE_LIMIT_CONFIG
: { ...DAFFY_RATE_LIMIT_CONFIG, enabled: false, maxRetries: 0 },
});
}
Loading
Loading