Skip to content

Commit

Permalink
refactor: use image previews generated by Figma's REST API to avoid a… (
Browse files Browse the repository at this point in the history
#551)

* refactor: use image previews generated by Figma's REST API to avoid auth issues with Figma embeds

* chore: add changeset
  • Loading branch information
mark-tate authored Jan 6, 2024
1 parent ae7b8f0 commit 9a0fd66
Show file tree
Hide file tree
Showing 4 changed files with 192 additions and 74 deletions.
5 changes: 5 additions & 0 deletions .changeset/fair-chairs-lay.md
Original file line number Diff line number Diff line change
@@ -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
85 changes: 54 additions & 31 deletions packages/source-figma/src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -72,17 +73,17 @@ const createExpectedResult = (patternId: string, data: Record<string, any>) => (
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',
Expand All @@ -93,17 +94,29 @@ const createExpectedResult = (patternId: string, data: Record<string, any>) => (

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 ', () => {
Expand All @@ -121,18 +134,28 @@ describe('GIVEN a Figma Source ', () => {
const source$: Observable<FigmaPage[]> = 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<string, any> = {
...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<string, any> = {
...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()
});
Expand Down
128 changes: 103 additions & 25 deletions packages/source-figma/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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)
})
Expand All @@ -56,31 +60,48 @@ const FigmaSource: Source<FigmaSourceOptions, FigmaPage> = {
requestHeaders: requestHeadersParam
} = parsedOptions;

const projectEndpoints = projects.map(project =>
endpoints.getProject.replace(':project_id', project.id.toString())
);
const projectEndpoints = projects.reduce<Record<string, string>>((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<FileUrlAndMeta[]>((allProjectFileUrls, { files }) => {
const projectFileUrls = files.reduce<FileUrlAndMeta[]>((project, { key }) => {
ProjectsResponse,
ProjectsTransformerResult
> = (
response: ProjectsResponse,
_prefixDir,
index,
transformerOptions: ProjectsTransformerOptions
) => {
return [response].reduce<ProjectsTransformerResult[]>((allProjectFileUrls, { files }) => {
const projectFileUrls = files.reduce<ProjectsTransformerResult[]>((project, { key }) => {
const meta: Record<string, any> = {
...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 }
Expand Down Expand Up @@ -109,26 +130,50 @@ const FigmaSource: Source<FigmaSourceOptions, FigmaPage> = {
}, []);
};

const projects$ = createHttpSource<ProjectFilesResponseJson, FileUrlAndMeta>(
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<ProjectsResponse, ProjectsTransformerResult>(
{
endpoints: projectEndpoints,
endpoints: Object.values(projectEndpoints),
requestHeaders: {
'Content-Type': 'application/json',
'X-FIGMA-TOKEN': figmaToken,
...requestHeadersParam
},
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<ProjectResponseJson, FigmaPage>({
return createHttpSource<ProjectFilesResponse, FigmaPage>({
endpoints: fileUrls,
requestHeaders: {
'Content-Type': 'application/json',
Expand All @@ -138,12 +183,45 @@ const FigmaSource: Source<FigmaSourceOptions, FigmaPage> = {
proxyEndpoint,
prefixDir,
transformer: projectFilesTransformer,
transformerOptions: fileUrlAndMetaCollection
transformerOptions: projectFiles
});
})
);

return figmaSource$;
const figmaPagesWithThumbnails$ = figmaPages$.pipe(
switchMap(figmaPages => {
const thumbnailNodes = figmaPages.reduce<Record<string, string[]>>(
(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<GenerateThumbnailResponse, FigmaPage>({
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$;
}
};

Expand Down
Loading

1 comment on commit 9a0fd66

@vercel
Copy link

@vercel vercel bot commented on 9a0fd66 Jan 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

mosaic – ./

mosaic-mosaic-dev-team.vercel.app
mosaic-git-main-mosaic-dev-team.vercel.app

Please sign in to comment.