From 9a0fd668ce829c3088e4c5873cbabe1d055f40e3 Mon Sep 17 00:00:00 2001 From: mark-tate <143323+mark-tate@users.noreply.github.com> Date: Sat, 6 Jan 2024 14:19:14 +0000 Subject: [PATCH] =?UTF-8?q?refactor:=20use=20image=20previews=20generated?= =?UTF-8?q?=20by=20Figma's=20REST=20API=20to=20avoid=20a=E2=80=A6=20(#551)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: use image previews generated by Figma's REST API to avoid auth issues with Figma embeds * chore: add changeset --- .changeset/fair-chairs-lay.md | 5 + .../source-figma/src/__tests__/index.test.ts | 85 +++++++----- packages/source-figma/src/index.ts | 128 ++++++++++++++---- packages/source-figma/src/types/index.ts | 48 ++++--- 4 files changed, 192 insertions(+), 74 deletions(-) create mode 100644 .changeset/fair-chairs-lay.md diff --git a/.changeset/fair-chairs-lay.md b/.changeset/fair-chairs-lay.md new file mode 100644 index 00000000..486dc614 --- /dev/null +++ b/.changeset/fair-chairs-lay.md @@ -0,0 +1,5 @@ +--- +'@jpmorganchase/mosaic-source-figma': minor +--- + +use image previews generated by Figma's REST API to avoid auth issues with Figma embeds diff --git a/packages/source-figma/src/__tests__/index.test.ts b/packages/source-figma/src/__tests__/index.test.ts index d6602fcb..77c367f2 100644 --- a/packages/source-figma/src/__tests__/index.test.ts +++ b/packages/source-figma/src/__tests__/index.test.ts @@ -35,31 +35,32 @@ const options = { } ], endpoints: { - getProject: 'https://myfigma.com/:project_id/files', - getFile: 'https://myfigma.com/:file_id?plugin_data=shared' + getProject: 'https://myfigma.com/getproject/:project_id/files', + getFile: 'https://myfigma.com/getfile/:file_id?plugin_data=shared', + generateThumbnail: 'https://myfigma.com/generatethumb/:project_id?ids=:node_id' } }; const getProjectById = (id: number) => options.projects.find(item => item.id === id) || { meta: { tags: [] } }; -const createProjectsResponse = (patternId: string) => ({ +const createProjectsResponse = (fileId: string) => ({ name: 'Figma Test Patterns', files: [ { - key: patternId + key: fileId } ] }); -const createProjectFilesResponse = (patternId: string) => ({ +const createProjectFilesResponse = (patternId: string, nodeId: string) => ({ document: { sharedPluginData: { - [`jpmSaltPattern.${patternId}`]: { + [patternId]: { description: `some description for ${patternId}`, link: 'some link', name: patternId, - node: 'X:Y', + nodeId, embedLink: 'some embed link', tags: 'some-tag1,some-tag2', version: 'some version' @@ -72,17 +73,17 @@ const createExpectedResult = (patternId: string, data: Record) => ( title: patternId, description: `some description for ${patternId}`, layout: 'DetailTechnical', - route: `/prefixdir/jpmsaltpattern_${patternId}`, - fullPath: `/prefixdir/jpmsaltpattern_${patternId}.json`, + route: `/prefixdir/${patternId}`.toLowerCase(), + fullPath: `/prefixdir/${patternId}.json`.toLowerCase(), tags: ['some-tag1', 'some-tag2'], ...data, data: { description: `some description for ${patternId}`, embedLink: 'some embed link', link: 'some link', - node: 'X:Y', + nodeId: '2:0', name: patternId, - patternId: `jpmSaltPattern.${patternId}`, + patternId: patternId, source: 'FIGMA', tags: 'some-tag1,some-tag2', version: 'some version', @@ -93,17 +94,29 @@ const createExpectedResult = (patternId: string, data: Record) => ( const successHandlers = [ // Projects - rest.get('https://myfigma.com/:project_id/*', (req, res, ctx) => { + rest.get('https://myfigma.com/getproject/:project_id/*', (req, res, ctx) => { const { project_id } = req.params; - const pattern = project_id === '888' ? 'pattern1' : 'pattern2'; - return res(ctx.status(200), ctx.json(createProjectsResponse(pattern))); + const fileId = project_id === '888' ? 'file888' : 'file999'; + return res(ctx.status(200), ctx.json(createProjectsResponse(fileId))); }), - // Patterns - rest.get('https://myfigma.com/pattern1', (req, res, ctx) => { - return res(ctx.status(200), ctx.json(createProjectFilesResponse('pattern1'))); + // Files + rest.get('https://myfigma.com/getfile/:file_id', (req, res, ctx) => { + const { file_id } = req.params; + const pattern = + file_id === 'file888' ? 'jpmSaltPattern_888_pattern1' : 'jpmSaltPattern_999_pattern2'; + return res(ctx.status(200), ctx.json(createProjectFilesResponse(pattern, '2:0'))); }), - rest.get('https://myfigma.com/pattern2', (req, res, ctx) => { - return res(ctx.status(200), ctx.json(createProjectFilesResponse('pattern2'))); + // Thumbnails + rest.get('https://myfigma.com/generatethumb/:project_id', (req, res, ctx) => { + const { project_id } = req.params; + const url = new URL(req.url); + const nodeId = url.searchParams.get('ids') as string; + return res( + ctx.status(200), + ctx.json({ + images: { [nodeId]: `/thumbnail/${project_id}/${nodeId}` } + }) + ); }) ]; describe('GIVEN a Figma Source ', () => { @@ -121,18 +134,28 @@ describe('GIVEN a Figma Source ', () => { const source$: Observable = Source.create(options, { schedule }); source$.pipe(take(1)).subscribe({ next: result => { - expect(result[0]).toEqual( - createExpectedResult('pattern1', { - ...getProjectById(888).meta, - tags: ['some-tag1', 'some-tag2', ...getProjectById(888).meta.tags] - }) - ); - expect(result[1]).toEqual( - createExpectedResult('pattern2', { - ...getProjectById(999).meta, - tags: ['some-tag1', 'some-tag2', ...getProjectById(999).meta.tags] - }) - ); + const meta0: Record = { + ...getProjectById(888).meta, + tags: ['some-tag1', 'some-tag2', ...getProjectById(888).meta.tags] + }; + meta0.data = { + ...meta0.data, + thumbnailUrl: `/thumbnail/file888/2:0`, + fileId: 'file888', + projectId: '888' + }; + expect(result[0]).toEqual(createExpectedResult('jpmSaltPattern_888_pattern1', meta0)); + const meta1: Record = { + ...getProjectById(999).meta, + tags: ['some-tag1', 'some-tag2', ...getProjectById(999).meta.tags] + }; + meta1.data = { + ...meta1.data, + fileId: 'file999', + thumbnailUrl: `/thumbnail/file999/2:0`, + projectId: '999' + }; + expect(result[1]).toEqual(createExpectedResult('jpmSaltPattern_999_pattern2', meta1)); }, complete: () => done() }); diff --git a/packages/source-figma/src/index.ts b/packages/source-figma/src/index.ts index 92d17423..18ab7c03 100644 --- a/packages/source-figma/src/index.ts +++ b/packages/source-figma/src/index.ts @@ -12,9 +12,12 @@ import { import createFigmaPage from './transformer.js'; import type { FigmaPage, - FileUrlAndMeta, - ProjectFilesResponseJson, - ProjectResponseJson + GenerateThumbnailResponse, + GenerateThumbnailTransformerOptions, + ProjectFilesResponse, + ProjectsResponse, + ProjectsTransformerOptions, + ProjectsTransformerResult } from './types/index.js'; const baseSchema = httpSourceCreatorSchema.omit({ @@ -35,7 +38,8 @@ export const schema = baseSchema.merge( ), endpoints: z.object({ getFile: z.string().url(), - getProject: z.string().url() + getProject: z.string().url(), + generateThumbnail: z.string().url() }), requestTimeout: z.number().default(5000) }) @@ -56,31 +60,48 @@ const FigmaSource: Source = { requestHeaders: requestHeadersParam } = parsedOptions; - const projectEndpoints = projects.map(project => - endpoints.getProject.replace(':project_id', project.id.toString()) - ); + const projectEndpoints = projects.reduce>((result, project) => { + const projectId = project.id.toString(); + return { + ...result, + [projectId]: endpoints.getProject.replace(':project_id', projectId) + }; + }, {}); const projectsTransformer: HttpSourceResponseTransformerType< - ProjectFilesResponseJson, - FileUrlAndMeta - > = (response: ProjectFilesResponseJson, _prefixDir, index) => - [response].reduce((allProjectFileUrls, { files }) => { - const projectFileUrls = files.reduce((project, { key }) => { + ProjectsResponse, + ProjectsTransformerResult + > = ( + response: ProjectsResponse, + _prefixDir, + index, + transformerOptions: ProjectsTransformerOptions + ) => { + return [response].reduce((allProjectFileUrls, { files }) => { + const projectFileUrls = files.reduce((project, { key }) => { + const meta: Record = { + ...projects[index].meta + }; + meta.data = { + ...meta.data, + fileId: key, + projectId: transformerOptions.projectIds[index] + }; const fileUrlAndMetadata = { fileUrl: endpoints.getFile.replace(':file_id', key), - meta: projects[index].meta + meta }; - return [...project, fileUrlAndMetadata]; }, []); return [...allProjectFileUrls, ...projectFileUrls]; }, []); + }; const projectFilesTransformer = ( - response: ProjectResponseJson, + response: ProjectFilesResponse, _prefixDir: string, index: number, - transformerOptions: FileUrlAndMeta[] + transformerOptions: ProjectsTransformerResult[] ) => { const { document: { sharedPluginData } @@ -109,9 +130,32 @@ const FigmaSource: Source = { }, []); }; - const projects$ = createHttpSource( + const generateThumbnailTransformer = ( + response: GenerateThumbnailResponse, + _prefixDir: string, + index: number, + transformerOptions: GenerateThumbnailTransformerOptions + ) => { + const fileId = transformerOptions.fileIds[index]; + if (response.err) { + console.error(`Figma returned ${response.err} for ${fileId} thumbnail generation `); + return transformerOptions.pages; + } + const thumbnailNodes = Object.keys(response.images); + thumbnailNodes.forEach(thumbnailNodeId => { + const pageForNode = transformerOptions.pages.find( + page => page.data.fileId === fileId && page.data.nodeId === thumbnailNodeId + ); + if (pageForNode) { + pageForNode.data.thumbnailUrl = response.images[thumbnailNodeId]; + } + }); + return transformerOptions.pages; + }; + + const projects$ = createHttpSource( { - endpoints: projectEndpoints, + endpoints: Object.values(projectEndpoints), requestHeaders: { 'Content-Type': 'application/json', 'X-FIGMA-TOKEN': figmaToken, @@ -119,16 +163,17 @@ const FigmaSource: Source = { }, proxyEndpoint, prefixDir, - transformer: projectsTransformer + transformer: projectsTransformer, + transformerOptions: { projectIds: Object.keys(projectEndpoints) } }, sourceConfig ); - const figmaSource$ = projects$.pipe( - switchMap(fileUrlAndMetaCollection => { - const fileUrls = fileUrlAndMetaCollection.map(item => item.fileUrl); + const figmaPages$ = projects$.pipe( + switchMap(projectFiles => { + const fileUrls = projectFiles.map(item => item.fileUrl); - return createHttpSource({ + return createHttpSource({ endpoints: fileUrls, requestHeaders: { 'Content-Type': 'application/json', @@ -138,12 +183,45 @@ const FigmaSource: Source = { proxyEndpoint, prefixDir, transformer: projectFilesTransformer, - transformerOptions: fileUrlAndMetaCollection + transformerOptions: projectFiles }); }) ); - return figmaSource$; + const figmaPagesWithThumbnails$ = figmaPages$.pipe( + switchMap(figmaPages => { + const thumbnailNodes = figmaPages.reduce>( + (thumbnailNodeMap, page) => { + const { fileId } = page.data; + if (thumbnailNodeMap[fileId]) { + thumbnailNodeMap[fileId] = [...thumbnailNodeMap[fileId], page.data.nodeId]; + } else { + thumbnailNodeMap[fileId] = [page.data.nodeId]; + } + return thumbnailNodeMap; + }, + {} + ); + + const thumbnailRequestUrls = Object.keys(thumbnailNodes).map(fileId => { + let generateThumbnailUrl = endpoints.generateThumbnail.replace(':project_id', fileId); + return generateThumbnailUrl.replace(':node_id', thumbnailNodes[fileId].join(',')); + }); + return createHttpSource({ + endpoints: thumbnailRequestUrls, + requestHeaders: { + 'Content-Type': 'application/json', + 'X-FIGMA-TOKEN': figmaToken, + ...requestHeadersParam + }, + proxyEndpoint, + prefixDir, + transformer: generateThumbnailTransformer, + transformerOptions: { fileIds: Object.keys(thumbnailNodes), pages: figmaPages } + }); + }) + ); + return figmaPagesWithThumbnails$; } }; diff --git a/packages/source-figma/src/types/index.ts b/packages/source-figma/src/types/index.ts index 8abeb6d0..f5434245 100644 --- a/packages/source-figma/src/types/index.ts +++ b/packages/source-figma/src/types/index.ts @@ -1,44 +1,56 @@ import type { Page } from '@jpmorganchase/mosaic-types'; -/** Get project response */ -export type ProjectResponseJson = { - document: { - id: string; - name: string; - sharedPluginData: Record; - }; - name: string; - lastModified: string; - thumbnailUrl: string; - version: string; +/** Get Projects Observable */ +export type ProjectsTransformerOptions = { + projectIds: string[]; +}; +export type ProjectsTransformerResult = { + fileUrl: string; + meta?: Record; }; - export type ProjectFile = { key: string; thumbnail_url: string; last_modified: string; branches?: ProjectFile[]; }; - -/** Get project files response */ -export type ProjectFilesResponseJson = { +export type ProjectsResponse = { name: string; files: ProjectFile[]; }; -export type FileUrlAndMeta = { - fileUrl: string; - meta?: Record; +/** Get Project Files Observable */ +export type ProjectFilesResponse = { + document: { + id: string; + name: string; + sharedPluginData: Record; + }; + name: string; + lastModified: string; + thumbnailUrl: string; + version: string; +}; + +/** Generate Thumbnail Observable */ +export type GenerateThumbnailResponse = { + err: null | number; + images: Record; }; +export type GenerateThumbnailTransformerOptions = { fileIds: string[]; pages: FigmaPage[] }; /** Figma page Metadata */ export type FigmaPageData = { name: string; patternId: string; + projectId: string; + fileId: string; description: string; embedLink: string; + nodeId: string; link: string; source: 'FIGMA'; + thumbnailUrl?: string; tags?: string; };