|
| 1 | +import { RequestMiddleware, Variables } from 'graphql-request/build/esm/types'; |
| 2 | + |
| 3 | +/** |
| 4 | + * Creates a request middleware that automatically handles file uploads |
| 5 | + * by converting requests to multipart/form-data when files are detected in variables. |
| 6 | + * |
| 7 | + * This middleware intercepts the request before it's sent, checks for File/Blob objects |
| 8 | + * in the variables, and if found, converts the request body to FormData following |
| 9 | + * the GraphQL multipart request specification. |
| 10 | + * |
| 11 | + * @param existingMiddleware - Optional existing middleware to chain with |
| 12 | + * @returns A RequestMiddleware function that handles file uploads |
| 13 | + */ |
| 14 | +export const createFileUploadMiddleware = (existingMiddleware?: RequestMiddleware): RequestMiddleware => { |
| 15 | + return async (request) => { |
| 16 | + const processedRequest = existingMiddleware ? await existingMiddleware(request) : request; |
| 17 | + |
| 18 | + const { variables, body } = processedRequest; |
| 19 | + |
| 20 | + if (!variables || !hasFiles(variables)) { |
| 21 | + return processedRequest; |
| 22 | + } |
| 23 | + |
| 24 | + if(typeof body !== 'string') { |
| 25 | + return processedRequest; |
| 26 | + } |
| 27 | + |
| 28 | + let query: string; |
| 29 | + try { |
| 30 | + const parsed = JSON.parse(body); |
| 31 | + query = parsed.query; |
| 32 | + } catch { |
| 33 | + // If we can't parse the body, return the request as-is |
| 34 | + return processedRequest; |
| 35 | + } |
| 36 | + |
| 37 | + const { cleanedVariables, files } = extractFiles(variables); |
| 38 | + const formData = createMultipartFormData(query, cleanedVariables, files); |
| 39 | + |
| 40 | + const headers = { ...(processedRequest.headers as Record<string, string>) }; |
| 41 | + for (const key of Object.keys(headers)) { |
| 42 | + if (key.toLowerCase() === 'content-type') { |
| 43 | + delete headers[key]; |
| 44 | + } |
| 45 | + } |
| 46 | + |
| 47 | + return { |
| 48 | + ...processedRequest, |
| 49 | + body: formData, |
| 50 | + headers, |
| 51 | + }; |
| 52 | + }; |
| 53 | +}; |
| 54 | + |
| 55 | +interface FileEntry { |
| 56 | + path: string; |
| 57 | + file: File | Blob; |
| 58 | +} |
| 59 | + |
| 60 | +/** |
| 61 | + * Checks if a value is a File or Blob |
| 62 | + */ |
| 63 | +const isFile = (value: unknown): value is File | Blob => { |
| 64 | + return (typeof File !== 'undefined' && value instanceof File) || (typeof Blob !== 'undefined' && value instanceof Blob); |
| 65 | +}; |
| 66 | + |
| 67 | +/** |
| 68 | + * Checks if variables contain any File or Blob objects |
| 69 | + */ |
| 70 | +const hasFiles = (variables: Variables | undefined): boolean => { |
| 71 | + if (!variables) { |
| 72 | + return false; |
| 73 | + } |
| 74 | + |
| 75 | + const check = (value: unknown): boolean => { |
| 76 | + if (isFile(value)) { |
| 77 | + return true; |
| 78 | + } |
| 79 | + if (Array.isArray(value)) { |
| 80 | + return value.some(check); |
| 81 | + } |
| 82 | + if (value !== null && typeof value === 'object') { |
| 83 | + return Object.values(value).some(check); |
| 84 | + } |
| 85 | + return false; |
| 86 | + }; |
| 87 | + |
| 88 | + return check(variables); |
| 89 | +} |
| 90 | + |
| 91 | +/** |
| 92 | + * Recursively extracts files from variables and returns the cleaned variables |
| 93 | + * along with file mappings for the multipart request spec. |
| 94 | + * |
| 95 | + * @param variables - The original variables object |
| 96 | + * @param path - Current path in the object (for building variable paths) |
| 97 | + * @returns Object with cleaned variables (files replaced with null) and file mappings |
| 98 | + */ |
| 99 | +const extractFiles = ( |
| 100 | + variables: Variables, |
| 101 | + path = 'variables' |
| 102 | +): { cleanedVariables: Variables; files: FileEntry[] } => { |
| 103 | + const files: FileEntry[] = []; |
| 104 | + |
| 105 | + const processValue = (value: unknown, currentPath: string): unknown => { |
| 106 | + if (isFile(value)) { |
| 107 | + files.push({ path: currentPath, file: value }); |
| 108 | + return null; // Replace file with null per the GraphQL multipart request specification |
| 109 | + } |
| 110 | + |
| 111 | + if (Array.isArray(value)) { |
| 112 | + return value.map((item, index) => processValue(item, `${currentPath}.${index}`)); |
| 113 | + } |
| 114 | + |
| 115 | + if (value === null || typeof value !== 'object') { |
| 116 | + return value; |
| 117 | + } |
| 118 | + |
| 119 | + const result: Record<string, unknown> = {}; |
| 120 | + for (const [key, val] of Object.entries(value)) { |
| 121 | + result[key] = processValue(val, `${currentPath}.${key}`); |
| 122 | + } |
| 123 | + return result; |
| 124 | + }; |
| 125 | + |
| 126 | + const cleanedVariables = processValue(variables, path) as Variables; |
| 127 | + return { cleanedVariables, files }; |
| 128 | +} |
| 129 | + |
| 130 | +/** |
| 131 | + * Creates a FormData object for GraphQL multipart file upload requests |
| 132 | + * following the GraphQL multipart request specification. |
| 133 | + */ |
| 134 | +const createMultipartFormData = (query: string, variables: Variables, files: FileEntry[]): FormData => { |
| 135 | + const formData = new FormData(); |
| 136 | + |
| 137 | + formData.append('query', query); |
| 138 | + formData.append('variables', JSON.stringify(variables)); |
| 139 | + |
| 140 | + const map: Record<string, string[]> = {}; |
| 141 | + for (const [index, entry] of Object.entries(files)) { |
| 142 | + map[String(index)] = [entry.path]; |
| 143 | + } |
| 144 | + |
| 145 | + formData.append('map', JSON.stringify(map)); |
| 146 | + |
| 147 | + |
| 148 | + for (const [index, entry] of Object.entries(files)) { |
| 149 | + formData.append(String(index), entry.file); |
| 150 | + } |
| 151 | + |
| 152 | + return formData; |
| 153 | +} |
| 154 | + |
0 commit comments