diff --git a/packages/snaps-sdk/package.json b/packages/snaps-sdk/package.json index bea57e44ef..f4dc71d79a 100644 --- a/packages/snaps-sdk/package.json +++ b/packages/snaps-sdk/package.json @@ -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", diff --git a/packages/snaps-sdk/src/images.test.ts b/packages/snaps-sdk/src/images.test.ts new file mode 100644 index 0000000000..11dc8e62e6 --- /dev/null +++ b/packages/snaps-sdk/src/images.test.ts @@ -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": "", + } + `); + }); + + 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": "", + } + `); + }); +}); diff --git a/packages/snaps-sdk/src/images.ts b/packages/snaps-sdk/src/images.ts new file mode 100644 index 0000000000..87df60f7ef --- /dev/null +++ b/packages/snaps-sdk/src/images.ts @@ -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 = ` + * + * + * + * `; + * + * // 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( + ``, + ); +} diff --git a/packages/snaps-sdk/src/index.ts b/packages/snaps-sdk/src/index.ts index cb353f11ab..49ac68bab1 100644 --- a/packages/snaps-sdk/src/index.ts +++ b/packages/snaps-sdk/src/index.ts @@ -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'; diff --git a/yarn.lock b/yarn.lock index 59fa28afa9..caf7c5b21e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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