Skip to content

Commit

Permalink
Add image fetching utility functions
Browse files Browse the repository at this point in the history
  • Loading branch information
Mrtenz committed Nov 28, 2023
1 parent 6e02577 commit 50df144
Show file tree
Hide file tree
Showing 5 changed files with 231 additions and 0 deletions.
1 change: 1 addition & 0 deletions packages/snaps-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"eslint-plugin-promise": "^6.1.1",
"expect-type": "^0.17.3",
"jest": "^29.0.2",
"jest-fetch-mock": "^3.0.3",
"jest-it-up": "^2.0.0",
"prettier": "^2.7.1",
"prettier-plugin-packagejson": "^2.2.11",
Expand Down
102 changes: 102 additions & 0 deletions packages/snaps-sdk/src/images.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import fetchMock from 'jest-fetch-mock';

import { getImageComponent, getImageData } from './images';

fetchMock.enableMocks();

describe('getImageData', () => {
beforeEach(() => {
fetchMock.resetMocks();
});

it('returns the image data as a data-string for a PNG image', async () => {
fetchMock.mockResponse('image data', {
headers: {
'Content-Type': 'image/png',
},
});

const imageData = await getImageData('https://example.com/image.png');
expect(imageData).toMatchInlineSnapshot(
`"data:image/png;base64,aW1hZ2UgZGF0YQ=="`,
);
});

it('returns the image data as a data-string for a JPEG image', async () => {
fetchMock.mockResponse('image data', {
headers: {
'Content-Type': 'image/jpeg',
},
});

const imageData = await getImageData('https://example.com/image.jpeg');
expect(imageData).toMatchInlineSnapshot(
`"data:image/jpeg;base64,aW1hZ2UgZGF0YQ=="`,
);
});

it('throws an error if the image is not a PNG or JPEG image', async () => {
fetchMock.mockResponse('image data', {
headers: {
'Content-Type': 'image/gif',
},
});

await expect(getImageData('https://example.com/image.gif')).rejects.toThrow(
'Expected image data to be a JPEG or PNG image.',
);
});

it('throws an error if the fetch request fails', async () => {
fetchMock.mockResponse(async () => ({
status: 404,
statusText: 'Not Found',
}));

await expect(getImageData('https://example.com/image.gif')).rejects.toThrow(
'Failed to fetch image data from "https://example.com/image.gif": 404 Not Found',
);
});
});

describe('getImage', () => {
beforeEach(() => {
fetchMock.resetMocks();
});

it('returns the image data as an image component', async () => {
fetchMock.mockResponse('image data', {
headers: {
'Content-Type': 'image/png',
},
});

const result = await getImageComponent('https://example.com/image.png');
expect(result).toMatchInlineSnapshot(`
{
"type": "image",
"value": "<svg xmlns="http://www.w3.org/2000/svg"><image href="data:image/png;base64,aW1hZ2UgZGF0YQ==" /></svg>",
}
`);
});

it('returns the image data as an image component with width and height', async () => {
fetchMock.mockResponse('image data', {
headers: {
'Content-Type': 'image/png',
},
});

const result = await getImageComponent('https://example.com/image.png', {
width: 100,
height: 100,
});

expect(result).toMatchInlineSnapshot(`
{
"type": "image",
"value": "<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg"><image href="data:image/png;base64,aW1hZ2UgZGF0YQ==" /></svg>",
}
`);
});
});
125 changes: 125 additions & 0 deletions packages/snaps-sdk/src/images.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { assert, bytesToBase64 } from '@metamask/utils';

import { image } from './ui';

/**
* Get raw image data from a URL.
*
* @param url - The URL to get the image data from.
* @param options - The options to use when fetching the image data. This is
* passed directly to `fetch`.
* @returns A promise that resolves to the image data as a blob.
*/
async function getRawImageData(url: string, options?: RequestInit) {
return fetch(url, options).then(async (response) => {
if (!response.ok) {
throw new Error(
`Failed to fetch image data from "${url}": ${response.status} ${response.statusText}`,
);
}

const blob = await response.blob();
assert(
blob.type === 'image/jpeg' || blob.type === 'image/png',
'Expected image data to be a JPEG or PNG image.',
);

return blob;
});
}

/**
* Get image data as data-string from a URL. This is useful for embedding images
* inside of SVGs. Only JPEG and PNG images are supported.
*
* Note: This function uses `fetch` to get the image data. This means that using
* it requires the `endowment:network-access` permission.
*
* @example
* const imageData = await getImageData('https://cataas.com/cat');
* const svg = `
* <svg width="100" height="100" xmlns="http://www.w3.org/2000/svg">
* <image href="${imageData}" />
* </svg>
* `;
*
* // Render the SVG in a Snap UI.
* const ui = image(svg);
* @param url - The URL to get the image data from.
* @param options - The options to use when fetching the image data. This is
* passed directly to `fetch`.
* @returns A promise that resolves to the image data as a data-string.
*/
export async function getImageData(url: string, options?: RequestInit) {
const blob = await getRawImageData(url, options);
const bytes = new Uint8Array(await blob.arrayBuffer());

return `data:${blob.type};base64,${bytesToBase64(bytes)}`;
}

/**
* Options for getting an SVG image element from a URL.
*
* @property width - The width of the image. If this is not provided, the image
* will be rendered at its original width.
* @property height - The height of the image. If this is not provided, the
* image will be rendered at its original height.
* @property request - The options to use when fetching the image data. This is
* passed directly to `fetch`.
*/
export type ImageOptions = {
width?: number;
height?: number;
request?: RequestInit;
};

/**
* Get an image component from a URL. This is useful for embedding images inside
* Snap UIs. Only JPEG and PNG images are supported.
*
* Note: This function uses `fetch` to get the image data. This means that using
* it requires the `endowment:network-access` permission.
*
* @example
* const component = await getImage('https://cataas.com/cat');
*
* return await snap.request({
* method: 'snap_dialog',
* params: {
* type: 'alert',
* content: panel([
* component,
* ]),
* },
* });
* @param url - The URL to get the image data from.
* @param options - The options to use when fetching and rendering the image.
* @param options.width - The width of the image. If this is not provided, the
* image will be rendered at its original width.
* @param options.height - The height of the image. If this is not provided, the
* image will be rendered at its original height.
* @param options.request - The options to use when fetching the image data.
* This is passed directly to `fetch`.
* @returns A promise that resolves to the image data as an image component.
*/
export async function getImageComponent(
url: string,
{ width, height, request }: ImageOptions = {},
) {
const imageData = await getImageData(url, request);

let size = '';
if (width) {
assert(width > 0, 'Expected width to be greater than 0.');
size += `width="${width}" `;
}

if (height) {
assert(height > 0, 'Expected height to be greater than 0.');
size += `height="${height}"`;
}

return image(
`<svg ${size.trim()} xmlns="http://www.w3.org/2000/svg"><image href="${imageData}" /></svg>`,
);
}
2 changes: 2 additions & 0 deletions packages/snaps-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ export type {
JsonRpcRequest,
JsonRpcParams,
} from '@metamask/utils';
export { assert } from '@metamask/utils';

export * from './errors';
export * from './images';
export * from './types';
export * from './ui';
1 change: 1 addition & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5518,6 +5518,7 @@ __metadata:
expect-type: ^0.17.3
is-svg: ^4.4.0
jest: ^29.0.2
jest-fetch-mock: ^3.0.3
jest-it-up: ^2.0.0
prettier: ^2.7.1
prettier-plugin-packagejson: ^2.2.11
Expand Down

0 comments on commit 50df144

Please sign in to comment.