Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/null-upload-operations.md
Original file line number Diff line number Diff line change
@@ -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.
59 changes: 59 additions & 0 deletions packages/houdini-core/runtime/plugins/fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof fakeResponse>) {
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 },
})
})
38 changes: 32 additions & 6 deletions packages/houdini-core/runtime/plugins/fetch.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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,
})

Expand All @@ -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,
})
)
}
Expand All @@ -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<unknown, string[]>): 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)

Expand Down
Loading