Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,8 @@
NEXT_PUBLIC_MAINNET_RPC_URL=
NEXT_PUBLIC_DEVNET_RPC_URL=
NEXT_PUBLIC_TESTNET_RPC_URL=
# Configuration for "metadata" service. set "ENABLED" to true to use it
NEXT_PUBLIC_METADATA_ENABLED=false
NEXT_PUBLIC_METADATA_TIMEOUT=
NEXT_PUBLIC_METADATA_MAX_CONTENT_SIZE=
NEXT_PUBLIC_METADATA_USER_AGENT="Solana Explorer"
88 changes: 88 additions & 0 deletions app/api/metadata/proxy/__tests__/endpoint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* @jest-environment node
*/
import fetch, { Headers } from 'node-fetch';

import { GET } from '../route';

function setEnvironment(key: string, value: string) {
Object.assign(process.env, { ...process.env, [key]: value });
}

jest.mock('node-fetch', () => {
const originalFetch = jest.requireActual('node-fetch')
const mockFn = jest.fn();

Object.assign(mockFn, originalFetch);

return mockFn
});

async function mockFileResponseOnce(data: any, headers: Headers){
// @ts-expect-error unavailable mock method for fetch
fetch.mockResolvedValueOnce({ headers, json: async () => data });
}

const ORIGIN = 'http://explorer.solana.com';

function requestFactory(uri?: string) {
const params = new URLSearchParams({ uri: uri ?? '' });
const request = new Request(`${ORIGIN}/api/metadata/devnet?${params.toString()}`);
const nextParams = { params: { network: 'devnet' } };

return { nextParams, request };
}

describe('metadata/[network] endpoint', () => {
const validUrl = encodeURIComponent('http://external.resource/file.json');
const unsupportedUri = encodeURIComponent('ftp://unsupported.resource/file.json');

afterEach(() => {
jest.clearAllMocks();
})

it('should return status when disabled', async () => {
setEnvironment('NEXT_PUBLIC_METADATA_ENABLED', 'false');

const { request, nextParams } = requestFactory();
const response = await GET(request, nextParams);
expect(response.status).toBe(404);
});

it('should return 400 for URIs with unsupported protocols', async () => {
setEnvironment('NEXT_PUBLIC_METADATA_ENABLED', 'true');

const request = requestFactory(unsupportedUri);
const response = await GET(request.request, request.nextParams);
expect(response.status).toBe(400);
});

it('should return proper status upon processig data', async () => {
setEnvironment('NEXT_PUBLIC_METADATA_ENABLED', 'true')

const { request, nextParams } = requestFactory();
const response = await GET(request, nextParams);
expect(response.status).toBe(400);

// fail on encoded incorrectly input
const request2 = requestFactory('https://example.com/%E0%A4%A');
expect((await GET(request2.request, request2.nextParams)).status).toBe(400);

// fail due to unexpected error
const request3 = requestFactory(validUrl);
const result = await GET(request3.request, request3.nextParams);
expect(result.status).toBe(500);
});

it('should handle valid response successfully', async () => {
await mockFileResponseOnce({ attributes: [], name: "NFT" }, new Headers({
'Cache-Control': 'no-cache',
'Content-Length': '140',
'Content-Type': 'application/json',
'Etag': 'random-etag',
}));

const request = requestFactory(validUrl);
expect((await GET(request.request, request.nextParams)).status).toBe(200);
})
});
126 changes: 126 additions & 0 deletions app/api/metadata/proxy/__tests__/fetch-resource.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* @jest-environment node
*/
import fetch, { Headers } from 'node-fetch';

import { fetchResource } from '../feature';

jest.mock('node-fetch', () => {
const originalFetch = jest.requireActual('node-fetch')
const mockFn = jest.fn();

Object.assign(mockFn, originalFetch);

return mockFn
});

/**
* mock valid response
*/
function mockFetchOnce(data: any = {}, headers: Headers = new Headers()) {
// @ts-expect-error fetch does not have mocked fn
fetch.mockResolvedValueOnce({
headers,
json: async () => data
});
}

function mockFetchbinaryOnce(data: any = {}, headers: Headers = new Headers()) {
// @ts-expect-error fetch does not have mocked fn
fetch.mockResolvedValueOnce({
arrayBuffer: async () => Buffer.from(data),
headers
});
}

/**
* mock error during process
*/
function mockRejectOnce<T extends Error>(error: T) {
// @ts-expect-error fetch does not have mocked fn
fetch.mockRejectedValueOnce(error);
}

describe('fetchResource', () => {
const uri = 'http://hello.world/data.json' ;
const headers = new Headers({ 'Content-Type': 'application/json' });

afterEach(() => {
jest.clearAllMocks();
})

it('should be called with proper arguments', async () => {
mockFetchOnce({}, new Headers({ 'Content-Type': 'application/json, charset=utf-8' }));

const resource = await fetchResource(uri, headers, 100, 100);

expect(fetch).toHaveBeenCalledWith(uri, expect.anything());
expect(resource.data).toEqual({});
})

it('should throw exception for unsupported media', async () => {
mockFetchOnce();

expect(() => {
return fetchResource(uri, headers, 100, 100);
}).rejects.toThrowError('Unsupported Media Type');
})

it('should throw exception upon exceeded size', async () => {
mockRejectOnce(new Error('FetchError: content size at https://path/to/resour.ce over limit: 100'));

expect(() => {
return fetchResource(uri, headers, 100, 100);
}).rejects.toThrowError('Max Content Size Exceeded');
})

it('should handle AbortSignal', async () => {
class TimeoutError extends Error {
constructor() {
super()
this.name = 'TimeoutError'
}
}
mockRejectOnce(new TimeoutError());

expect(() => {
return fetchResource(uri, headers, 100, 100);
}).rejects.toThrowError('Gateway Timeout')
})

it('should handle size overflow', async () => {
mockRejectOnce(new Error('file is over limit: 100'));

expect(() => {
return fetchResource(uri, headers, 100, 100);
}).rejects.toThrowError('Max Content Size Exceeded')
})

it('should handle unexpected result', async () => {
// @ts-expect-error fetch does not have mocked fn
fetch.mockRejectedValueOnce({ data: "unexpected exception" });

const fn = () => {
return fetchResource(uri, headers, 100, 100);
}

try {
await fn();
} catch(e: any) {
expect(e.message).toEqual('General Error')
expect(e.status).toEqual(500)
}
})

it('should handle malformed JSON response gracefully', async () => {
// Mock fetch to return a response with invalid JSON
// @ts-expect-error fetch does not have mocked fn
fetch.mockResolvedValueOnce({
headers: new Headers({ 'Content-Type': 'application/json' }),
// Simulate malformed JSON by rejecting during json parsing
json: async () => { throw new SyntaxError('Unexpected token < in JSON at position 0') }
});

await expect(fetchResource(uri, headers, 100, 100)).rejects.toThrowError('Unsupported Media Type');
});
})
40 changes: 40 additions & 0 deletions app/api/metadata/proxy/feature/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
export class StatusError extends Error {
status: number;
constructor(message: string, options: ErrorOptions & { cause: number }) {
super(message);
this.status = options.cause;
}
}

export const invalidRequestError = new StatusError('Invalid Request', { cause: 400 });

export const resourceNotFoundError = new StatusError('Resource Not Found', { cause: 404 });

export const maxSizeError = new StatusError('Max Content Size Exceeded', { cause: 413 });

export const unsupportedMediaError = new StatusError('Unsupported Media Type', { cause: 415 });

export const generalError = new StatusError('General Error', { cause: 500 });

export const gatewayTimeoutError = new StatusError('Gateway Timeout', { cause: 504 });

export const errors = {
400: invalidRequestError,
404: resourceNotFoundError,
413: maxSizeError,
415: unsupportedMediaError,
500: generalError,
504: gatewayTimeoutError,
}

export function matchAbortError(error: unknown): error is Error {
return Boolean(error instanceof Error && error.name === 'AbortError');
}

export function matchMaxSizeError(error: unknown): error is Error {
return Boolean(error instanceof Error && error.message.match(/over limit:/));
}

export function matchTimeoutError(error: unknown): error is Error {
return Boolean(error instanceof Error && error.name === 'TimeoutError');
}
83 changes: 83 additions & 0 deletions app/api/metadata/proxy/feature/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { default as fetch, Headers } from 'node-fetch';

import { errors, matchAbortError, matchMaxSizeError, matchTimeoutError, StatusError, unsupportedMediaError } from './errors';
import { processBinary, processJson } from './processors';

export { StatusError };

/**
* use this to handle errors that are thrown by fetch.
* it will throw size-specific ones, for example, when the resource is json
*/
function handleRequestBasedErrors(error: Error | undefined) {
if (matchTimeoutError(error)) {
return errors[504];
} else if (matchMaxSizeError(error)) {
return errors[413];
}else if (matchAbortError(error)) {
return errors[504];
} else {
return errors[500];
}
}

async function requestResource(
uri: string,
headers: Headers,
timeout: number,
size: number
): Promise<[Error, void] | [void, fetch.Response]> {
let response: fetch.Response | undefined;
let error;
try {
response = await fetch(uri, {
headers,
signal: AbortSignal.timeout(timeout),
size,
});

return [undefined, response];
} catch (e) {
if (e instanceof Error) {
error = e;
} else {
// handle any other error as general one and allow to see it at console
// might be a good one to track with a service like Sentry
console.debug(e);
error = new Error("Cannot fetch resource");

}
}

return [error, undefined];
}

export async function fetchResource(
uri: string,
headers: Headers,
timeout: number,
size: number
): Promise<Awaited<|
ReturnType<typeof processBinary> |
ReturnType<typeof processJson>
>> {
const [error, response] = await requestResource(uri, headers, timeout, size);

// check for response to infer proper type for it
// and throw proper error
if (error || !response) {
throw handleRequestBasedErrors(error ?? undefined);
}

// guess how to process resource by content-type
const isJson = response.headers.get('content-type')?.includes('application/json');

const isImage = response.headers.get('content-type')?.includes('image/');

if (isJson) return processJson(response);

if (isImage) return processBinary(response);

// otherwise we throw error as we getting unexpected content
throw unsupportedMediaError
}
Loading