Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
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
9 changes: 6 additions & 3 deletions packages/corsair/core/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@ export const BaseProviders = [
'agenty',
'ahrefs',
'aimlapi',
'allimagesai',
'airtable',
'alchemy',
'algolia',
'allimagesai',
'alphavantage',
'altoviz',
'alttextai',
Expand Down Expand Up @@ -175,6 +175,7 @@ export const BaseProviders = [
'twochat',
'typeform',
'unione',
'uniswapapi',
'vapi',
'vercel',
'webflow',
Expand Down Expand Up @@ -205,10 +206,10 @@ export const ProviderDisplayNames = {
agenty: 'Agenty',
ahrefs: 'Ahrefs',
aimlapi: 'AI/ML API',
allimagesai: 'All Images AI',
airtable: 'Airtable',
alchemy: 'Alchemy',
algolia: 'Algolia',
allimagesai: 'All Images AI',
alphavantage: 'Alpha Vantage',
altoviz: 'Altoviz',
alttextai: 'AltText.ai',
Expand Down Expand Up @@ -352,6 +353,7 @@ export const ProviderDisplayNames = {
twochat: 'TwoChat',
typeform: 'Typeform',
unione: 'Unione',
uniswapapi: 'UniswapApi',
vapi: 'Vapi',
vercel: 'Vercel',
webflow: 'Webflow',
Expand Down Expand Up @@ -389,10 +391,10 @@ export type AllProviders =
| 'agenty'
| 'ahrefs'
| 'aimlapi'
| 'allimagesai'
| 'airtable'
| 'alchemy'
| 'algolia'
| 'allimagesai'
| 'alphavantage'
| 'altoviz'
| 'alttextai'
Expand Down Expand Up @@ -536,6 +538,7 @@ export type AllProviders =
| 'twochat'
| 'typeform'
| 'unione'
| 'uniswapapi'
| 'vapi'
| 'vercel'
| 'webflow'
Expand Down
75 changes: 75 additions & 0 deletions packages/uniswapapi/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { ApiError, request } from 'corsair/http';
import { makeUniswapApiRequest, UniswapApiAPIError } from './client';
import { errorHandlers } from './error-handlers';

jest.mock('corsair/http', () => ({
...jest.requireActual('corsair/http'),
request: jest.fn(),
}));

const mockedRequest = request as jest.MockedFunction<typeof request>;

function apiError(status: number, retryAfter?: number): ApiError {
return new ApiError(
{ method: 'GET', url: '/v1/orders' },
{
url: 'https://trade-api.gateway.uniswap.org/v1/orders',
ok: false,
status,
statusText: 'Too Many Requests',
body: {
detail: 'Please slow down before trying again.',
errorCode: 'TOO_MANY_REQUESTS',
},
},
'Please slow down before trying again.',
{ retryAfter },
);
}

async function captureError(promise: Promise<unknown>) {
try {
await promise;
} catch (error) {
return error as UniswapApiAPIError;
}
throw new Error('expected the request to reject');
}

beforeEach(() => {
mockedRequest.mockReset();
});

describe('makeUniswapApiRequest', () => {
it('preserves status and retryAfter on wrapped ApiError', async () => {
mockedRequest.mockRejectedValueOnce(apiError(429, 2500));

const error = await captureError(
makeUniswapApiRequest('/v1/orders', 'key'),
);

expect(error).toBeInstanceOf(UniswapApiAPIError);
expect(error.status).toBe(429);
expect(error.retryAfter).toBe(2500);
expect(error.code).toBe('TOO_MANY_REQUESTS');
});
});

describe('errorHandlers', () => {
it('routes a wrapped 429 without relying on message text', async () => {
mockedRequest.mockRejectedValueOnce(apiError(429, 2500));
const error = await captureError(
makeUniswapApiRequest('/v1/orders', 'key'),
);

expect(error.message).not.toContain('429');
expect(error.message).not.toContain('rate_limited');
expect(errorHandlers.RATE_LIMIT_ERROR.match(error)).toBe(true);
await expect(
errorHandlers.RATE_LIMIT_ERROR.handler(error),
).resolves.toEqual({
maxRetries: 5,
headersRetryAfterMs: 2500,
});
});
});
110 changes: 110 additions & 0 deletions packages/uniswapapi/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import type { ApiRequestOptions, OpenAPIConfig } from 'corsair/http';
import { ApiError, request } from 'corsair/http';

type UniswapApiErrorOptions = {
cause?: Error;
status?: number;
statusText?: string;
body?: unknown;
retryAfter?: number;
};

export class UniswapApiAPIError extends Error {
public readonly status?: number;
public readonly statusText?: string;
public readonly body?: unknown;
public readonly retryAfter?: number;

constructor(
message: string,
public readonly code?: string,
options: UniswapApiErrorOptions = {},
) {
super(message, options);
this.name = 'UniswapApiAPIError';

if (options.cause instanceof ApiError) {
this.status = options.cause.status;
this.statusText = options.cause.statusText;
this.body = options.cause.body;
this.retryAfter = options.cause.retryAfter;
} else {
this.status = options.status;
this.statusText = options.statusText;
this.body = options.body;
this.retryAfter = options.retryAfter;
}
}
}

const UNISWAPAPI_API_BASE = 'https://trade-api.gateway.uniswap.org';

export async function makeUniswapApiRequest<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: UNISWAPAPI_API_BASE,
VERSION: '1.0.0',
WITH_CREDENTIALS: false,
CREDENTIALS: 'omit',
TOKEN: apiKey,
HEADERS: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'x-permit2-disabled': 'false',
},
};

const requestOptions: ApiRequestOptions = {
method,
url: endpoint,
body:
method === 'POST' || method === 'PUT' || method === 'PATCH'
? body
: undefined,
Comment thread
yuvanvk marked this conversation as resolved.
mediaType: 'application/json; charset=utf-8',
query: method === 'GET' ? query : undefined,
};

try {
return await request<T>(config, requestOptions);
} catch (error) {
if (error instanceof ApiError) {
// UniswapApi error responses use { errorCode, detail } instead of the
// generic { code, message } shape — extract those fields explicitly,
// falling back to error.message / error.status if the body doesn't match.
const body = error.body;

const message =
typeof body === 'object' &&
body !== null &&
'detail' in body &&
typeof body.detail === 'string'
? body.detail
: error.message;

const code =
typeof body === 'object' &&
body !== null &&
'errorCode' in body &&
typeof body.errorCode === 'string'
? body.errorCode
: error.status?.toString();
throw new UniswapApiAPIError(message, code, { cause: error });
}

if (error instanceof Error) {
throw new UniswapApiAPIError(error.message);
}

throw new UniswapApiAPIError('Unknown error');
}
}
32 changes: 32 additions & 0 deletions packages/uniswapapi/endpoints/approval.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { logEventFromContext } from 'corsair/core';
import type { UniswapApiEndpoints } from '..';
import { makeUniswapApiRequest } from '../client';
import type { UniswapApiEndpointOutputs } from './types';
import { UniswapApiEndpointOutputSchemas } from './types';

export const check: UniswapApiEndpoints['approvalCheck'] = async (
ctx,
input,
) => {
const response = await makeUniswapApiRequest<
UniswapApiEndpointOutputs['approvalCheck']
>('/v1/check_approval', ctx.key, {
method: 'POST',
body: {
token: input.token,
amount: input.amount,
walletAddress: input.walletAddress,
chainId: input.chainId,
},
});
const parsedResponse =
UniswapApiEndpointOutputSchemas.approvalCheck.parse(response);

await logEventFromContext(
ctx,
'uniswapapi.approval.check',
{ ...input },
'completed',
);
return parsedResponse;
};
30 changes: 30 additions & 0 deletions packages/uniswapapi/endpoints/delegation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { logEventFromContext } from 'corsair/core';
import type { UniswapApiEndpoints } from '..';
import { makeUniswapApiRequest } from '../client';
import type { UniswapApiEndpointOutputs } from './types';
import { UniswapApiEndpointOutputSchemas } from './types';

export const check: UniswapApiEndpoints['delegationCheck'] = async (
ctx,
input,
) => {
const response = await makeUniswapApiRequest<
UniswapApiEndpointOutputs['delegationCheck']
>('/v1/check_delegation', ctx.key, {
method: 'POST',
body: {
walletAddress: input.walletAddress,
chainIds: input.chainIds,
},
});
const parsedResponse =
UniswapApiEndpointOutputSchemas.delegationCheck.parse(response);

await logEventFromContext(
ctx,
'uniswapapi.delegation.check',
{ ...input },
'completed',
);
return parsedResponse;
};
Loading
Loading