From 08642bcbda2f8aa65d79cde4f43c99175bbc669f Mon Sep 17 00:00:00 2001 From: Ben Ayles <1235055+knd775@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:40:27 -0400 Subject: [PATCH] fix: send null for uploaded files in multipart operations Provided bodies are patched too: defaultFetch and plugins that set fetchParams.body serialize each File as {}, so nulling only the fallback body wouldn't fix either. --- .changeset/null-upload-operations.md | 6 ++ .../runtime/plugins/fetch.test.ts | 59 +++++++++++++++++++ .../houdini-core/runtime/plugins/fetch.ts | 38 ++++++++++-- 3 files changed, 97 insertions(+), 6 deletions(-) create mode 100644 .changeset/null-upload-operations.md diff --git a/.changeset/null-upload-operations.md b/.changeset/null-upload-operations.md new file mode 100644 index 000000000..bbe0369e6 --- /dev/null +++ b/.changeset/null-upload-operations.md @@ -0,0 +1,6 @@ +--- +'houdini': patch +'houdini-core': patch +--- + +Send `null` in place of each file in multipart upload requests, as the GraphQL multipart request spec requires. diff --git a/packages/houdini-core/runtime/plugins/fetch.test.ts b/packages/houdini-core/runtime/plugins/fetch.test.ts index fd6fcb72a..6f11fc98c 100644 --- a/packages/houdini-core/runtime/plugins/fetch.test.ts +++ b/packages/houdini-core/runtime/plugins/fetch.test.ts @@ -213,3 +213,62 @@ test('throws on error responses that are not GraphQL media types', async () => { }) ).rejects.toThrow('Failed to fetch: server returned invalid response with error 503') }) + +function sentOperations(fetchMock: ReturnType) { + const [, args] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + if (!(args.body instanceof FormData)) { + throw new Error('expected a multipart body') + } + return { + operations: JSON.parse(String(args.body.get('operations'))), + map: JSON.parse(String(args.body.get('map'))), + } +} + +test('multipart uploads replace files with null in operations', async () => { + const fetchMock = fakeResponse({ body: { data: { viewer: null } } }) + const store = createStore({ pipeline: [fetchPlugin()] }) + + const file = new File(['hello'], 'hello.txt') + await store.send({ fetch: fetchMock, variables: { id: '1', file, files: [file] } }) + + expect(sentOperations(fetchMock)).toEqual({ + operations: { + operationName: 'TestArtifact', + query: 'RAW_TEXT', + variables: { id: '1', file: null, files: [null] }, + }, + map: { 1: ['variables.file', 'variables.files.0'] }, + }) +}) + +test('multipart uploads replace files with null in a custom body', async () => { + const fetchMock = fakeResponse({ body: { data: { viewer: null } } }) + // a persisted-operations style plugin that builds its own body from the variables + const store = createStore({ + pipeline: [ + () => ({ + beforeNetwork(ctx, { next, marshalVariables }) { + next({ + ...ctx, + fetchParams: { + body: JSON.stringify({ + extensions: { persistedQuery: { sha256Hash: 'abc' } }, + variables: marshalVariables(ctx), + }), + }, + }) + }, + }), + fetchPlugin(), + ], + }) + + const file = new File(['hello'], 'hello.txt') + await store.send({ fetch: fetchMock, variables: { id: '1', file } }) + + expect(sentOperations(fetchMock).operations).toEqual({ + extensions: { persistedQuery: { sha256Hash: 'abc' } }, + variables: { id: '1', file: null }, + }) +}) diff --git a/packages/houdini-core/runtime/plugins/fetch.ts b/packages/houdini-core/runtime/plugins/fetch.ts index 34e6042b3..ddd1897e4 100644 --- a/packages/houdini-core/runtime/plugins/fetch.ts +++ b/packages/houdini-core/runtime/plugins/fetch.ts @@ -1,4 +1,4 @@ -import { getAuthUrl } from 'houdini/runtime' +import { getAuthUrl, valueAtPath } from 'houdini/runtime' import type { ClientPlugin, ClientPluginContext } from 'houdini/runtime/documentStore' import { ArtifactKind, DataSource } from 'houdini/runtime/types' import type { RequestPayload, FetchContext } from 'houdini/runtime/types' @@ -222,7 +222,7 @@ function handleMultipart( args: RequestInit | undefined ): RequestInit | undefined { // process any files that could be included - const { files } = extractFiles({ + const { clone, files } = extractFiles({ variables: params.variables, }) @@ -246,16 +246,17 @@ function handleMultipart( // https://github.com/jaydenseric/graphql-multipart-request-spec const form = new FormData() - // if we have a body, just use it. - if (args?.body) { - form.set('operations', args?.body as string) + // files in the provided body were serialized to {}, but the spec and strict servers + // require null at each file path + if (typeof args?.body === 'string') { + form.set('operations', nullFilePaths(args.body, files)) } else { form.set( 'operations', JSON.stringify({ operationName: params.name, query: params.text, - variables: params.variables, + variables: clone.variables, }) ) } @@ -277,6 +278,31 @@ function handleMultipart( } } +// the file map's paths (e.g. variables.files.0) index straight into the parsed body. +// A body that isn't JSON can't be patched, so it's sent as-is. +function nullFilePaths(body: string, files: Map): string { + let operations: unknown + try { + operations = JSON.parse(body) + } catch { + return body + } + + for (const paths of files.values()) { + for (const path of paths) { + const keys = path.split('.') + const last = keys.pop()! + const target: unknown = valueAtPath(operations, keys) + // the container is an object or an array (for a file in a list) + if (typeof target === 'object' && target !== null && last in target) { + Reflect.set(target, last, null) + } + } + } + + return JSON.stringify(operations) +} + /// This file contains a modified version of the functions found here: https://github.com/jaydenseric/extract-files/blob/master/extractFiles.mjs /// The associated license is at the end of the file (per the project's license agreement)