Skip to content

Add support for Graphql file upload #434

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 8 commits into from
Closed
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 packages/openapi-to-graphql/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ For example, let's say that there was a library API that would allow you to get

Notice that the slashes in the path `/favoriteBooks/{name}` must be escaped with `~1` and that you can compose parameter values with different [runtime expressions](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#runtimeExpression) using brackets.

### File Uploads

Support for file uploads through GraphQL is implemented based on the spec defined [here](https://github.com/jaydenseric/graphql-multipart-request-spec), through the module [`graphql-upload`](https://github.com/jaydenseric/graphql-upload). This requires that to opt-in for file uploads, it is required that a GraphQL client that provides support for the spec is used, additionally, on the Graphql Server the util method `processRequest` provided by the module `graphql-upload` is integrated to format the received request body to match the expectations for the spec.

### Options

The `createGraphQLSchema` function takes an optional `options` object as a second argument:
Expand Down Expand Up @@ -183,6 +187,8 @@ Resolver options:

- `customSubscriptionResolvers` (type: `object`, default: `{}`): If the `createSubscriptionsFromCallbacks` is enabled, OpenAPI-to-GraphQL will generate Subscription fields. This option allows users to provide custom resolver and subscribe functions to be used in place of said ones created by OpenAPI-to-GraphQL. The field that the custom resolver and subscribe functions will affect is identifed first by the [title](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#infoObject) of the OAS, then the [path](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#paths-object) of the operation, and lastly the [method](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#path-item-object) of the operation. The resolver is provided via the `resolver` field and the publish function is provided via the `publish` field. The `customSubscriptionResolvers` object is thus a quadruply nested object where the outer key is the title, followed by the path, then the method, and lastly either `resolver` or `publish` which points to the [resolver function](https://graphql.org/learn/execution/#root-fields-resolvers) itself or publish function. See the [Subscriptions tutorial](./docs/subscriptions.md) for more information. _Note: Because the arguments are provided by the GraphQL interface, they may look different from the [parameters](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#parameterObject) defined by the OAS. For example, they will have [sanitized](https://github.com/IBM/openapi-to-graphql#characteristics) names. The [request body](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#requestBodyObject) will also be contained in the arguments as an [input object type](https://graphql.org/graphql-js/mutations-and-input-types/)._

- `fileUploadOptions` (type: `object`, default: `{}`): This options allows users to provide custom options for the form data object that will be used to process requests to endpoints that require a multipart request body, provided through the [`form-data` module](https://github.com/form-data/form-data)

***

Authentication options:
Expand Down
4 changes: 4 additions & 0 deletions packages/openapi-to-graphql/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,11 @@
"cross-fetch": "^3.1.4",
"debug": "^4.2.0",
"deep-equal": "^2.0.5",
"form-data": "^4.0.0",
"form-urlencoded": "^6.0.4",
"graphql-scalars": "^1.10.0",
"graphql-subscriptions": "^1.1.0",
"graphql-upload": "^13.0.0",
"json-ptr": "^2.2.0",
"jsonpath-plus": "^6.0.1",
"oas-validator": "^5.0.2",
Expand All @@ -102,6 +104,7 @@
"devDependencies": {
"@types/deep-equal": "^1.0.1",
"@types/graphql": "^14.0.3",
"@types/graphql-upload": "^8.0.7",
"@types/jest": "^26.0.14",
"@types/node": "^16.3.3",
"@types/url-join": "^4.0.1",
Expand All @@ -119,6 +122,7 @@
"isomorphic-git": "^1.9.2",
"jest": "^27.0.6",
"js-yaml": "^4.1.0",
"memfs": "^3.4.0",
"mqemitter": "^4.4.0",
"mqtt": "^4.2.1",
"nodemon": "^2.0.12",
Expand Down
15 changes: 13 additions & 2 deletions packages/openapi-to-graphql/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ import {
InternalOptions,
Report,
ConnectOptions,
RequestOptions
RequestOptions,
FileUploadOptions
} from './types/options'
import { Oas3 } from './types/oas3'
import { Oas2 } from './types/oas2'
Expand Down Expand Up @@ -112,6 +113,7 @@ const DEFAULT_OPTIONS: InternalOptions<any, any, any> = {
requestOptions: {},
customResolvers: {},
customSubscriptionResolvers: {},
fileUploadOptions: {},

// Authentication options
viewer: true,
Expand Down Expand Up @@ -193,6 +195,7 @@ export function translateOpenAPIToGraphQL<TSource, TContext, TArgs>(
headers,
qs,
requestOptions,
fileUploadOptions,
connectOptions,
baseUrl,
customResolvers,
Expand Down Expand Up @@ -234,6 +237,7 @@ export function translateOpenAPIToGraphQL<TSource, TContext, TArgs>(
headers,
qs,
requestOptions,
fileUploadOptions,
connectOptions,
baseUrl,
customResolvers,
Expand Down Expand Up @@ -477,6 +481,7 @@ function addQueryFields<TSource, TContext, TArgs>({
singularNames,
baseUrl,
requestOptions,
fileUploadOptions,
connectOptions,
fetch
} = options
Expand All @@ -486,6 +491,7 @@ function addQueryFields<TSource, TContext, TArgs>({
baseUrl,
data,
requestOptions,
fileUploadOptions,
connectOptions,
fetch
)
Expand Down Expand Up @@ -654,6 +660,7 @@ function addMutationFields<TSource, TContext, TArgs>({
singularNames,
baseUrl,
requestOptions,
fileUploadOptions,
connectOptions,
fetch
} = options
Expand All @@ -663,6 +670,7 @@ function addMutationFields<TSource, TContext, TArgs>({
baseUrl,
data,
requestOptions,
fileUploadOptions,
connectOptions,
fetch
)
Expand Down Expand Up @@ -802,13 +810,14 @@ function addSubscriptionFields<TSource, TContext, TArgs>({
options: InternalOptions<TSource, TContext, TArgs>
data: PreprocessingData<TSource, TContext, TArgs>
}) {
const { baseUrl, requestOptions, connectOptions, fetch } = options
const { baseUrl, requestOptions, connectOptions, fetch, fileUploadOptions } = options

const field = getFieldForOperation(
operation,
baseUrl,
data,
requestOptions,
fileUploadOptions,
connectOptions,
fetch
)
Expand Down Expand Up @@ -912,6 +921,7 @@ function getFieldForOperation<TSource, TContext, TArgs>(
baseUrl: string,
data: PreprocessingData<TSource, TContext, TArgs>,
requestOptions: Partial<RequestOptions<TSource, TContext, TArgs>>,
fileUploadOptions: FileUploadOptions,
connectOptions: ConnectOptions,
fetch: typeof crossFetch
): GraphQLFieldConfig<TSource, TContext | SubscriptionContext, TArgs> {
Expand Down Expand Up @@ -978,6 +988,7 @@ function getFieldForOperation<TSource, TContext, TArgs>(
data,
baseUrl,
requestOptions,
fileUploadOptions,
fetch
})

Expand Down
7 changes: 5 additions & 2 deletions packages/openapi-to-graphql/src/oas_3_tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,9 @@ export function getSchemaTargetGraphQLType<TSource, TContext, TArgs>(
if (typeof schema.format === 'string') {
if (schema.type === 'integer' && schema.format === 'int64') {
return TargetGraphQLType.bigint

// CASE: file upload
} else if (schema.type === 'string' && schema.format === 'binary') {
return TargetGraphQLType.upload
// CASE: id
} else if (
schema.type === 'string' &&
Expand Down Expand Up @@ -840,7 +842,8 @@ export function getRequestSchemaAndNames(
if (
payloadContentType === 'application/json' ||
payloadContentType === '*/*' ||
payloadContentType === 'application/x-www-form-urlencoded'
payloadContentType === 'application/x-www-form-urlencoded' ||
payloadContentType === 'multipart/form-data'
) {
// Name extracted from a reference, if applicable
let fromRef: string
Expand Down
68 changes: 66 additions & 2 deletions packages/openapi-to-graphql/src/resolver_builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,27 @@ import { ConnectOptions } from './types/options'
import { TargetGraphQLType, Operation } from './types/operation'
import { SubscriptionContext } from './types/graphql'
import { PreprocessingData } from './types/preprocessing_data'
import { RequestOptions } from './types/options'
import { RequestOptions, FileUploadOptions } from './types/options'
import crossFetch from 'cross-fetch'
import { FileUpload } from 'graphql-upload'

// Imports:
import stream from 'stream'
import * as Oas3Tools from './oas_3_tools'
import { JSONPath } from 'jsonpath-plus'
import { debug } from 'debug'
import { GraphQLError, GraphQLFieldResolver } from 'graphql'
import formurlencoded from 'form-urlencoded'
import { PubSub } from 'graphql-subscriptions'
import urljoin from 'url-join'
import FormData from 'form-data'

const pubsub = new PubSub()

const translationLog = debug('translation')
const httpLog = debug('http')
const pubsubLog = debug('pubsub')
const uploadLog = debug('fileUpload')

// OAS runtime expression reference locations
const RUNTIME_REFERENCES = ['header.', 'query.', 'path.', 'body']
Expand Down Expand Up @@ -57,6 +61,7 @@ type GetResolverParams<TSource, TContext, TArgs> = {
data: PreprocessingData<TSource, TContext, TArgs>
baseUrl?: string
requestOptions?: Partial<RequestOptions<TSource, TContext, TArgs>>
fileUploadOptions?: FileUploadOptions
fetch: typeof crossFetch
}

Expand Down Expand Up @@ -323,13 +328,14 @@ export function getPublishResolver<TSource, TContext, TArgs>({
* If the operation type is Query or Mutation, create and return a resolver
* function that performs API requests for the given GraphQL query
*/
export function getResolver<TSource, TContext, TArgs>({
export function getResolver<TSource, TContext, TArgs> ({
operation,
argsFromLink = {},
payloadName,
data,
baseUrl,
requestOptions,
fileUploadOptions,
fetch
}: GetResolverParams<TSource, TContext, TArgs>): GraphQLFieldResolver<
TSource & OpenAPIToGraphQLSource<TSource, TContext, TArgs>,
Expand Down Expand Up @@ -554,6 +560,7 @@ export function getResolver<TSource, TContext, TArgs>({
* GraphQL produces sanitized payload names, so we have to sanitize before
* lookup here
*/
let form
resolveData.usedPayload = undefined
if (typeof payloadName === 'string') {
// The option genericPayloadArgName will change the payload name to "requestBody"
Expand All @@ -572,6 +579,56 @@ export function getResolver<TSource, TContext, TArgs>({
rawPayload = formurlencoded(
Oas3Tools.desanitizeObjectKeys(args[sanePayloadName], data.saneMap)
)
} else if (operation.payloadContentType === 'multipart/form-data') {
form = new FormData(fileUploadOptions)

const formFieldsPayloadEntries = Object.entries(args[sanePayloadName]);

(await Promise.all(formFieldsPayloadEntries.map(([_, v]) => v)))
.forEach((fieldValue, idx) => {
const fieldName = formFieldsPayloadEntries[idx][0]

if (typeof fieldValue === 'object' && Boolean((fieldValue as Partial<FileUpload>).createReadStream)) {
const uploadingFile = fieldValue as FileUpload
const originalFileStream = uploadingFile.createReadStream()
const filePassThrough = new stream.PassThrough()

originalFileStream.on('readable', function () {
let data
// tslint:disable-next-line:no-conditional-assignment
while (data = this.read()) {
const canReadNext = filePassThrough.write(data)
if (!canReadNext) {
this.pause()
filePassThrough.once('drain', () => this.resume())
}
}
})

originalFileStream.on('error', () => {
uploadLog('Encountered an error while uploading the file %s', uploadingFile.filename)
})

originalFileStream.on('end', () => {
uploadLog('Upload for received file %s completed', uploadingFile.filename)
filePassThrough.end()
})

uploadLog('Queuing upload for received file %s', uploadingFile.filename)

form.append(fieldName, filePassThrough, {
filename: uploadingFile.filename,
contentType: uploadingFile.mimetype
})
} else if (typeof fieldValue !== 'string') {
// handle all other primitives that aren't strings as strings the way the web server would expect it
form.append(fieldName, JSON.stringify(fieldValue))
} else {
form.append(fieldName, fieldValue)
}
})

rawPayload = form
} else {
// Payload is not an object
rawPayload = args[sanePayloadName]
Expand All @@ -598,6 +655,13 @@ export function getResolver<TSource, TContext, TArgs>({
if (typeof headers === 'object') {
Object.assign(options.headers, headers)
}

if (form) {
Object.assign(options.headers, form.getHeaders())
// when is form, remove default content type and leave computation of content-type header to fetch
// see https://github.com/github/fetch/issues/505#issuecomment-293064470
delete options.headers['content-type']
}
}

// Query string:
Expand Down
6 changes: 6 additions & 0 deletions packages/openapi-to-graphql/src/schema_builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
GraphQLInputType,
GraphQLInputFieldConfigMap
} from 'graphql'
import { GraphQLUpload } from 'graphql-upload'

// Imports:
import { GraphQLBigInt, GraphQLJSON } from 'graphql-scalars'
Expand Down Expand Up @@ -222,6 +223,10 @@ export function getGraphQLType<TSource, TContext, TArgs>({
case TargetGraphQLType.bigint:
def.graphQLType = GraphQLBigInt
return def.graphQLType

case TargetGraphQLType.upload:
def.graphQLType = GraphQLUpload
return def.graphQLType
}
}

Expand Down Expand Up @@ -780,6 +785,7 @@ function createFields<TSource, TContext, TArgs>({
data,
baseUrl: data.options.baseUrl,
requestOptions: data.options.requestOptions,
fileUploadOptions: data.options.fileUploadOptions,
fetch
})

Expand Down
1 change: 1 addition & 0 deletions packages/openapi-to-graphql/src/types/operation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export enum TargetGraphQLType {
boolean = 'boolean',
id = 'id',
bigint = 'bigint',
upload = 'upload',

// JSON
json = 'json',
Expand Down
20 changes: 20 additions & 0 deletions packages/openapi-to-graphql/src/types/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { GraphQLOperationType, SubscriptionContext } from './graphql'
import { GraphQLFieldResolver, GraphQLResolveInfo } from 'graphql'
import crossFetch from 'cross-fetch'
import FormData from 'form-data'

/**
* Type definition of the options that users can pass to OpenAPI-to-GraphQL.
Expand Down Expand Up @@ -76,6 +77,17 @@ export type RequestOptions<TSource, TContext, TArgs> = Omit<
qs?: Record<string, string>
}

/**
* We use the form-data library to prepare multipart requests within the resolver API calls,
* also it provides support for handling file as streams this way the file upload has a minimal memory footprint,
* unlike the situation where the entire file in memory initially.
*
* Provides accommodation to allow overrides or add options for how the form data representation for multipart requests is generated
*
* Based on: https://github.com/form-data/form-data#custom-options
*/
export type FileUploadOptions = ConstructorParameters<typeof FormData>[0]

export type Options<TSource, TContext, TArgs> = Partial<
InternalOptions<TSource, TContext, TArgs>
>
Expand Down Expand Up @@ -275,6 +287,14 @@ export type InternalOptions<TSource, TContext, TArgs> = {
resolve: GraphQLFieldResolver<TSource, TContext, TArgs>
}>

/**
* Allows one to define config for the form data that will be used in streaming
* the uploaded file from the client to the intending endpoint
*
* Based on https://github.com/form-data/form-data#custom-options
*/
fileUploadOptions?: FileUploadOptions

// Authentication options

/**
Expand Down
Loading