Skip to content

Commit

Permalink
Multipart upload support (#660)
Browse files Browse the repository at this point in the history
* feat: multipart upload support
Co-authored-by: https://github.com/AlecAivazis and https://github.com/524c

* comment in a header filter

* add single and multi upload support to api

* add store tests for single and multi upload files

* remove comment about lib 'formdata-node'

* fix test titles

* add changeset

* add upload files guide

* unnecessary await

Co-authored-by: Gogs <[email protected]>
Co-authored-by: Alec Aivazis <[email protected]>
  • Loading branch information
3 people authored Nov 1, 2022
1 parent 3b44a6e commit 08b3d10
Show file tree
Hide file tree
Showing 16 changed files with 367 additions and 4 deletions.
5 changes: 5 additions & 0 deletions .changeset/selfish-bottles-hang.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'houdini': patch
---

Add support for multipart file uploads
29 changes: 29 additions & 0 deletions e2e/_api/graphql.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@ function getSnapshot(snapshot) {
return snapshots[snapshot]
}

async function processFile(file) {
const fileStream = file.stream()
const filename = path.join('./', file.name)
await fs.promises.writeFile(filename, fileStream)
return await fs.promises.readFile(filename, 'utf8').then(async (data) => {
await fs.promises.unlink(filename)
return data
})
}

export const resolvers = {
Query: {
hello: () => {
Expand Down Expand Up @@ -135,6 +145,25 @@ export const resolvers = {
}
return list[userIndex]
},
singleUpload: async (_, { file }) => {
try {
let data = await processFile(file)
return data
} catch (e) {}
throw new GraphQLYogaError('ERROR', { code: 500 })
},
multipleUpload: async (_, { files }) => {
let res = []
for (let i in files) {
try {
let data = await processFile(files[i])
res.push(data)
} catch (e) {
throw new GraphQLYogaError('ERROR', { code: 500 })
}
}
return res
},
},

DateTime: new GraphQLScalarType({
Expand Down
3 changes: 3 additions & 0 deletions e2e/_api/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Date custom scalar type
"""
scalar DateTime
scalar File

enum MyEnum {
Value1
Expand All @@ -23,6 +24,8 @@ type Mutation {
types: [TypeOfUser!]
): User!
updateUser(id: ID!, name: String, snapshot: String!, birthDate: DateTime, delay: Int): User!
singleUpload(file: File!): String!
multipleUpload(files: [File!]!): [String!]!
}

interface Node {
Expand Down
3 changes: 3 additions & 0 deletions e2e/sveltekit/houdini.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ const config = {
marshal(val) {
return val.getTime();
}
},
File: {
type: 'File'
}
},
plugins: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
mutation multipleUpload($files: [File!]!) {
multipleUpload(files: $files)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
mutation singleUpload($file: File!) {
singleUpload(file: $file)
}
2 changes: 2 additions & 0 deletions e2e/sveltekit/src/lib/utils/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export const routes = {
Stores_Mutation: '/stores/mutation',
Stores_Mutation_Update: '/stores/mutation-update',
Stores_Mutation_Scalars: '/stores/mutation-scalars',
Stores_Mutation_Scalar_Single_Upload: '/stores/mutation-scalar-single-upload',
Stores_Mutation_Scalar_Multi_Upload: '/stores/mutation-scalar-multi-upload',
Stores_Mutation_Enums: '/stores/mutation-enums',
Stores_Network_One_Store_Multivariables: '/stores/network-one-store-multivariables',
Stores_SSR_One_Store_Multivariables: '/stores/ssr-one-store-multivariables',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<script lang="ts">
import { GQL_multipleUpload } from '$houdini';
async function upload() {
const file = new File(['Houdini'], 'foo.txt', {
type: 'text/plain'
});
GQL_multipleUpload.mutate({ files: [file, file] });
}
</script>

<h1>Mutation multi upload</h1>

<button id="mutate" on:click={upload}>Upload files</button>

<div id="result">
{JSON.stringify($GQL_multipleUpload.data)}
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { routes } from '../../../lib/utils/routes.js';
import { expect_1_gql, goto, expectToBe } from '../../../lib/utils/testsHelper.js';
import { test } from '@playwright/test';

test.describe('mutation store upload', function () {
test('multiple files', async function ({ page }) {
await goto(page, routes.Stores_Mutation_Scalar_Multi_Upload);

// trigger the mutation and wait for a response
await expect_1_gql(page, 'button[id=mutate]');

// make sure that the return data is equal file content (["Houdini","Houdini"])
await expectToBe(page, '{"multipleUpload":["Houdini","Houdini"]}');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<script lang="ts">
import { GQL_singleUpload } from '$houdini';
async function upload() {
const file = new File(['Houdini'], 'foo.txt', {
type: 'text/plain'
});
GQL_singleUpload.mutate({ file: file });
}
</script>

<h1>Mutation single upload</h1>

<button id="mutate" on:click={upload}>Upload file</button>

<div id="result">
{JSON.stringify($GQL_singleUpload.data)}
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { routes } from '../../../lib/utils/routes.js';
import { expect_1_gql, goto, expectToBe } from '../../../lib/utils/testsHelper.js';
import { test } from '@playwright/test';

test.describe('mutation store upload', function () {
test('single files', async function ({ page }) {
await goto(page, routes.Stores_Mutation_Scalar_Single_Upload);

// trigger the mutation and wait for a response
await expect_1_gql(page, 'button[id=mutate]');

// make sure that the return data is equal file content ("Houdini")
await expectToBe(page, '{"singleUpload":"Houdini"}');
});
});
59 changes: 58 additions & 1 deletion packages/houdini/src/runtime/lib/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import cache from '../cache'
import type { ConfigFile } from './config'
import * as log from './log'
import { extractFiles } from './networkUtils'
import {
CachePolicy,
DataSource,
Expand All @@ -22,6 +23,58 @@ export class HoudiniClient {
this.socket = subscriptionHandler
}

handleMultipart(
params: FetchParams,
args: Parameters<FetchContext['fetch']>
): Parameters<FetchContext['fetch']> | undefined {
const [url, req] = args

// process any files that could be included
const { clone, files } = extractFiles({
query: params.text,
variables: params.variables,
})

const operationJSON = JSON.stringify(clone)

// if there are files in the request
if (files.size) {
let headers: Record<string, string> = {}

// filters `content-type: application/json` if received by client.ts
if (req?.headers) {
const filtered = Object.entries(req?.headers).filter(([key, value]) => {
return !(
key.toLowerCase() == 'content-type' &&
value.toLowerCase() == 'application/json'
)
})
headers = Object.fromEntries(filtered)
}

// See the GraphQL multipart request spec:
// https://github.com/jaydenseric/graphql-multipart-request-spec
const form = new FormData()

form.set('operations', operationJSON)

const map: Record<string, Array<string>> = {}

let i = 0
files.forEach((paths) => {
map[++i] = paths
})
form.set('map', JSON.stringify(map))

i = 0
files.forEach((paths, file) => {
form.set(`${++i}`, file as Blob, (file as File).name)
})

return [url, { ...req, headers, body: form as any }]
}
}

async sendRequest<_Data>(
ctx: FetchContext,
params: FetchParams
Expand All @@ -33,7 +86,11 @@ export class HoudiniClient {
// wrap the user's fetch function so we can identify SSR by checking
// the response.url
fetch: async (...args: Parameters<FetchContext['fetch']>) => {
const response = await ctx.fetch(...args)
// figure out if we need to do something special for multipart uploads
const newArgs = this.handleMultipart(params, args)

// use the new args if they exist, otherwise the old ones are good
const response = await ctx.fetch(...(newArgs || args))
if (response.url) {
url = response.url
}
Expand Down
151 changes: 151 additions & 0 deletions packages/houdini/src/runtime/lib/networkUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/// This file contains a modified version, made by AlecAivazis, 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)

export function isExtractableFile(value: any): value is ExtractableFile {
return (
(typeof File !== 'undefined' && value instanceof File) ||
(typeof Blob !== 'undefined' && value instanceof Blob)
)
}

type ExtractableFile = File | Blob

/** @typedef {import("./isExtractableFile.mjs").default} isExtractableFile */

export function extractFiles(value: any) {
if (!arguments.length) throw new TypeError('Argument 1 `value` is required.')

/**
* Deeply clonable value.
* @typedef {Array<unknown> | FileList | Record<PropertyKey, unknown>} Cloneable
*/

/**
* Clone of a {@link Cloneable deeply cloneable value}.
* @typedef {Exclude<Cloneable, FileList>} Clone
*/

/**
* Map of values recursed within the input value and their clones, for reusing
* clones of values that are referenced multiple times within the input value.
* @type {Map<Cloneable, Clone>}
*/
const clones = new Map()

/**
* Extracted files and their object paths within the input value.
* @type {Extraction<Extractable>["files"]}
*/
const files = new Map()

/**
* Recursively clones the value, extracting files.
*/
function recurse(value: any, path: string | string[], recursed: Set<any>) {
if (isExtractableFile(value)) {
const filePaths = files.get(value)

filePaths ? filePaths.push(path) : files.set(value, [path])

return null
}

const valueIsList =
Array.isArray(value) || (typeof FileList !== 'undefined' && value instanceof FileList)
const valueIsPlainObject = isPlainObject(value)

if (valueIsList || valueIsPlainObject) {
let clone = clones.get(value)

const uncloned = !clone

if (uncloned) {
clone = valueIsList
? []
: // Replicate if the plain object is an `Object` instance.
value instanceof /** @type {any} */ Object
? {}
: Object.create(null)

clones.set(value, /** @type {Clone} */ clone)
}

if (!recursed.has(value)) {
const pathPrefix = path ? `${path}.` : ''
const recursedDeeper = new Set(recursed).add(value)

if (valueIsList) {
let index = 0

// @ts-ignore
for (const item of value) {
const itemClone = recurse(item, pathPrefix + index++, recursedDeeper)

if (uncloned) /** @type {Array<unknown>} */ clone.push(itemClone)
}
} else
for (const key in value) {
const propertyClone = recurse(value[key], pathPrefix + key, recursedDeeper)

if (uncloned)
/** @type {Record<PropertyKey, unknown>} */ clone[key] = propertyClone
}
}

return clone
}

return value
}

return {
clone: recurse(value, '', new Set()),
files,
}
}

/**
* An extraction result.
* @template [Extractable=unknown] Extractable file type.
* @typedef {object} Extraction
* @prop {unknown} clone Clone of the original value with extracted files
* recursively replaced with `null`.
* @prop {Map<Extractable, Array<ObjectPath>>} files Extracted files and their
* object paths within the original value.
*/

/**
* String notation for the path to a node in an object tree.
* @typedef {string} ObjectPath
* @see [`object-path` on npm](https://npm.im/object-path).
* @example
* An object path for object property `a`, array index `0`, object property `b`:
*
* ```
* a.0.b
* ```
*/

function isPlainObject(value: any) {
if (typeof value !== 'object' || value === null) {
return false
}

const prototype = Object.getPrototypeOf(value)
return (
(prototype === null ||
prototype === Object.prototype ||
Object.getPrototypeOf(prototype) === null) &&
!(Symbol.toStringTag in value) &&
!(Symbol.iterator in value)
)
}

// MIT License
// Copyright Jayden Seric

// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Loading

2 comments on commit 08b3d10

@vercel
Copy link

@vercel vercel bot commented on 08b3d10 Nov 1, 2022

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:

docs-next – ./site

docs-next-kohl.vercel.app
docs-next-houdinigraphql.vercel.app
docs-next-git-main-houdinigraphql.vercel.app

@vercel
Copy link

@vercel vercel bot commented on 08b3d10 Nov 1, 2022

Choose a reason for hiding this comment

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

Please sign in to comment.