figma-client.ts•7.37 kB
import axios from "axios";
import type { AxiosInstance } from "axios";
import type {
GetFileNodesResponse,
GetImagesResponse,
} from "@figma/rest-api-spec";
import {
handleAxiosError,
withTimeout,
withErrorHandling,
logger,
ErrorType,
FigmaServerError,
} from "../utils/error-handling.js";
import { isValidFileKey, isValidNodeId } from "../utils/validation.js";
export interface FigmaClientConfig {
apiToken: string;
baseURL?: string;
timeout?: number;
maxRetries?: number;
}
export class FigmaApiClient {
private instance: AxiosInstance;
private config: Required<FigmaClientConfig>;
private requestTimings: Map<string, number> = new Map();
constructor(config: FigmaClientConfig) {
this.config = {
baseURL: "https://api.figma.com/v1",
timeout: 30000,
maxRetries: 3,
...config,
};
this.instance = axios.create({
baseURL: this.config.baseURL,
headers: {
"X-Figma-Token": this.config.apiToken,
"User-Agent": "Figma-MCP-Server/1.0.0",
},
timeout: this.config.timeout,
});
this.setupInterceptors();
}
private setupInterceptors(): void {
this.instance.interceptors.request.use(
(config) => {
const requestKey = `${config.method}_${config.url}_${Date.now()}`;
this.requestTimings.set(requestKey, Date.now());
(config as any).__requestKey = requestKey;
logger.apiRequest(
config.method?.toUpperCase() || "UNKNOWN",
config.url || "unknown",
{
params: config.params,
timeout: config.timeout,
}
);
return config;
},
(error) => {
logger.error("Request interceptor error:", {
message: error.message,
stack: error.stack,
});
return Promise.reject(error);
}
);
this.instance.interceptors.response.use(
(response) => {
const requestKey = (response.config as any).__requestKey;
const startTime = this.requestTimings.get(requestKey);
const duration = startTime ? Date.now() - startTime : 0;
if (requestKey) {
this.requestTimings.delete(requestKey);
}
logger.apiResponse(
response.config.method?.toUpperCase() || "UNKNOWN",
response.config.url || "unknown",
response.status,
duration,
{
statusText: response.statusText,
dataSize: JSON.stringify(response.data).length,
}
);
return response;
},
(error) => {
const requestKey = (error.config as any)?.__requestKey;
const startTime = requestKey
? this.requestTimings.get(requestKey)
: null;
const duration = startTime ? Date.now() - startTime : 0;
if (requestKey) {
this.requestTimings.delete(requestKey);
}
if (error.response) {
logger.apiResponse(
error.config?.method?.toUpperCase() || "UNKNOWN",
error.config?.url || "unknown",
error.response.status,
duration,
{
statusText: error.response.statusText,
errorData: error.response.data,
}
);
}
handleAxiosError(error, "API Request");
}
);
}
/**
* Get file nodes with comprehensive error handling and validation.
*/
public getFileNodes = withErrorHandling(
"getFileNodes",
async (
fileKey: string,
nodeIds: string | string[]
): Promise<GetFileNodesResponse> => {
if (!isValidFileKey(fileKey)) {
throw new FigmaServerError(
"Invalid file key format",
ErrorType.VALIDATION_ERROR,
{
operation: "getFileNodes",
input: { fileKey },
timestamp: new Date().toISOString(),
},
400
);
}
const nodeIdArray = Array.isArray(nodeIds) ? nodeIds : [nodeIds];
for (const nodeId of nodeIdArray) {
if (!isValidNodeId(nodeId)) {
throw new FigmaServerError(
`Invalid node ID format: ${nodeId}`,
ErrorType.VALIDATION_ERROR,
{
operation: "getFileNodes",
input: { fileKey, nodeIds },
timestamp: new Date().toISOString(),
},
400
);
}
}
const nodeIdsString = nodeIdArray.join(",");
const response = await withTimeout(
this.instance.get<GetFileNodesResponse>(`/files/${fileKey}/nodes`, {
params: { ids: nodeIdsString },
}),
this.config.timeout,
"getFileNodes"
);
// Validate response structure
if (!response.data || !response.data.nodes) {
throw new FigmaServerError(
"Invalid response format from Figma API",
ErrorType.API_ERROR,
{
operation: "getFileNodes",
timestamp: new Date().toISOString(),
}
);
}
return response.data;
}
);
/**
* Get images with proper error handling.
*/
public getImages = withErrorHandling(
"getImages",
async (
fileKey: string,
nodeIds: string | string[],
format: "png" | "jpg" | "svg" | "pdf" = "png",
scale: number = 1
): Promise<GetImagesResponse> => {
if (!isValidFileKey(fileKey)) {
throw new FigmaServerError(
"Invalid file key format",
ErrorType.VALIDATION_ERROR,
{
operation: "getImages",
input: { fileKey },
timestamp: new Date().toISOString(),
},
400
);
}
const nodeIdArray = Array.isArray(nodeIds) ? nodeIds : [nodeIds];
for (const nodeId of nodeIdArray) {
if (!isValidNodeId(nodeId)) {
throw new FigmaServerError(
`Invalid node ID format: ${nodeId}`,
ErrorType.VALIDATION_ERROR,
{
operation: "getImages",
input: { fileKey, nodeIds },
timestamp: new Date().toISOString(),
},
400
);
}
}
if (scale < 0.01 || scale > 4) {
throw new FigmaServerError(
"Scale must be between 0.01 and 4",
ErrorType.VALIDATION_ERROR,
{
operation: "getImages",
input: { scale },
timestamp: new Date().toISOString(),
},
400
);
}
const nodeIdsString = nodeIdArray.join(",");
const response = await withTimeout(
this.instance.get<GetImagesResponse>(`/images/${fileKey}`, {
params: {
ids: nodeIdsString,
format,
scale: scale.toString(),
},
}),
this.config.timeout,
"getImages"
);
if (response.data.err) {
throw new FigmaServerError(
`Figma API error: ${response.data.err}`,
ErrorType.API_ERROR,
{
operation: "getImages",
timestamp: new Date().toISOString(),
}
);
}
return response.data;
}
);
/**
* Transform node ID format (hyphens to colons).
*/
public transformNodeId(nodeId: string): string {
return nodeId.replace(/-/g, ":");
}
}