diff --git a/.circleci/config.yml b/.circleci/config.yml index 5279a03..60c31ab 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -106,7 +106,7 @@ jobs: - npm-install - run: name: TypeScript - command: npm run build + command: npm run build -w @apollo/explorer-helpers && npm run build build-latest-umd-explorer: executor: node diff --git a/package-lock.json b/package-lock.json index 0ac0423..57a325c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9990,6 +9990,7 @@ "version": "3.4.0", "license": "MIT", "dependencies": { + "@apollo/explorer-helpers": "^0.1.4", "@types/whatwg-mimetype": "^3.0.0", "graphql-ws": "^5.9.0", "subscriptions-transport-ws": "^0.11.0", @@ -10019,7 +10020,7 @@ }, "packages/explorer-helpers": { "name": "@apollo/explorer-helpers", - "version": "0.1.3", + "version": "0.1.4", "license": "MIT", "dependencies": { "@types/whatwg-mimetype": "^3.0.0", @@ -10036,6 +10037,7 @@ "version": "2.4.0", "license": "MIT", "dependencies": { + "@apollo/explorer-helpers": "^0.1.4", "@types/whatwg-mimetype": "^3.0.0", "eventemitter3": "3.1.0", "graphql-ws": "^5.9.0", @@ -10078,6 +10080,7 @@ "@apollo/explorer": { "version": "file:packages/explorer", "requires": { + "@apollo/explorer-helpers": "^0.1.4", "@types/whatwg-mimetype": "^3.0.0", "graphql-ws": "^5.9.0", "subscriptions-transport-ws": "^0.11.0", @@ -10096,6 +10099,7 @@ "@apollo/sandbox": { "version": "file:packages/sandbox", "requires": { + "@apollo/explorer-helpers": "^0.1.4", "@types/whatwg-mimetype": "^3.0.0", "eventemitter3": "3.1.0", "graphql-ws": "^5.9.0", diff --git a/package.json b/package.json index f16133d..1c740f8 100644 --- a/package.json +++ b/package.json @@ -66,9 +66,9 @@ "endOfLine": "auto" }, "workspaces": [ + "packages/explorer-helpers", "packages/explorer", - "packages/sandbox", - "packages/explorer-helpers" + "packages/sandbox" ], "devDependencies": { "@babel/core": "^7.18.5", diff --git a/packages/explorer/package.json b/packages/explorer/package.json index 2b15053..aebd80b 100644 --- a/packages/explorer/package.json +++ b/packages/explorer/package.json @@ -69,6 +69,7 @@ } }, "dependencies": { + "@apollo/explorer-helpers": "^0.1.4", "@types/whatwg-mimetype": "^3.0.0", "graphql-ws": "^5.9.0", "subscriptions-transport-ws": "^0.11.0", diff --git a/packages/explorer/src/helpers/constructMultipartForm.ts b/packages/explorer/src/helpers/constructMultipartForm.ts deleted file mode 100644 index f017b70..0000000 --- a/packages/explorer/src/helpers/constructMultipartForm.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { JSONValue } from './types'; - -export type FileVariable = { - variableKey: string; - files: { arrayBuffer: ArrayBuffer; fileName: string }[]; - isMultiFile: boolean; -}; - -// https://github.com/apollographql/apollo-client/blob/cbcf951256b22553bdb065dfa0d32c0a4ca804d3/src/link/http/serializeFetchParameter.ts -export const serializeFetchParameter = (p: any, label: string) => { - let serialized; - try { - serialized = JSON.stringify(p); - } catch (e) { - const parseError = new Error( - `Network request failed. ${label} is not serializable` - ); - throw parseError; - } - return serialized; -}; - -export const constructMultipartForm = async ({ - fileVariables: inputtedFileVariables, - requestBody, -}: { - fileVariables: FileVariable[]; - requestBody: { - operationName?: string; - query: string; - variables?: Record; - }; -}) => { - const fileVariables: { - variableKey: string; - files: File[]; - isMultiFile: boolean; - }[] = inputtedFileVariables.map((fileVariable) => ({ - ...fileVariable, - files: fileVariable.files.map( - ({ arrayBuffer, fileName }) => - new File([new Blob([arrayBuffer])], fileName) - ), - })); - - // the map element of a FormData maps indices to a single item array of variable names - // as seen here https://github.com/jaydenseric/graphql-multipart-request-spec#file-list - const map: Record = {}; - let i = 0; - // map must be the first thing in the form, followed by the files - // other wise you get the error: - // Misordered multipart fields; files should follow ‘map’ (https://github.com/jaydenseric/graphql-multipart-request-spec). - const filesToAppend: [string, File, string][] = []; - // variables are added to the operation body with null values, the variable - // name is used to match them to files uploaded in the later part of the request - // according to the spec https://github.com/jaydenseric/graphql-multipart-request-spec - let variablesWithNullsForFiles: - | Record - | undefined = requestBody.variables; - fileVariables.forEach( - ({ files, variableKey, isMultiFile }, fileVariableIndex) => { - if (files?.length) { - variablesWithNullsForFiles = { - ...variablesWithNullsForFiles, - [variableKey]: isMultiFile - ? new Array(files.length).fill(null) - : null, - }; - Array.from(files).forEach((file) => { - map[i] = [ - `${ - fileVariables.length > 1 ? `${fileVariableIndex}.` : '' - }variables.${variableKey}${isMultiFile ? `.${i}` : ''}`, - ]; - // in the request, there is expected to be a number appended that corresponds to each file - // https://github.com/jaydenseric/graphql-multipart-request-spec#file-list - filesToAppend.push([i.toString(), file, file.name]); - i++; - }); - } - } - ); - const form = new FormData(); - form.append( - 'operations', - serializeFetchParameter( - { - query: requestBody.query, - operationName: requestBody.operationName, - variables: variablesWithNullsForFiles, - }, - 'Payload' - ) - ); - - form.append('map', JSON.stringify(map)); - filesToAppend.forEach((item) => { - form.append(item[0], item[1], item[2]); - }); - - return form; -}; diff --git a/packages/explorer/src/helpers/postMessageRelayHelpers.ts b/packages/explorer/src/helpers/postMessageRelayHelpers.ts index 58d25f3..f140688 100644 --- a/packages/explorer/src/helpers/postMessageRelayHelpers.ts +++ b/packages/explorer/src/helpers/postMessageRelayHelpers.ts @@ -23,14 +23,17 @@ import { EXPLORER_LISTENING_FOR_SCHEMA, } from './constants'; import MIMEType from 'whatwg-mimetype'; -import { readMultipartWebStream } from './readMultipartWebStream'; import type { JSONObject, JSONValue } from './types'; import type { ObjMap } from 'graphql/jsutils/ObjMap'; import type { GraphQLSubscriptionLibrary, HTTPMultipartClient, } from './subscriptionPostMessageRelayHelpers'; -import { constructMultipartForm, FileVariable } from './constructMultipartForm'; +import { + readMultipartWebStream, + constructMultipartForm, + FileVariable, +} from '@apollo/explorer-helpers'; export type HandleRequest = ( endpointUrl: string, @@ -89,21 +92,6 @@ type ExplorerResponse = ResponseData & { size?: number; }; -// https://apollographql.quip.com/mkWRAJfuxa7L/Multipart-subscriptions-protocol-spec -export interface MultipartSubscriptionResponse { - data: { - errors?: Array; - payload: - | (ResponseData & { - error?: { message: string; stack?: string }; - }) - | null; - }; - headers?: Record | Record[]; - size: number; - status?: number; -} - export type ExplorerSubscriptionResponse = // websocket response | { @@ -112,7 +100,20 @@ export type ExplorerSubscriptionResponse = errors?: GraphQLError[]; } // http multipart response options below - | MultipartSubscriptionResponse + // https://apollographql.quip.com/mkWRAJfuxa7L/Multipart-subscriptions-protocol-spec + | { + data: { + errors?: Array; + payload: + | (ResponseData & { + error?: { message: string; stack?: string }; + }) + | null; + }; + headers?: Record | Record[]; + size: number; + status?: number; + } | { data: null; // this only exists in the PM MultipartSubscriptionResponse @@ -248,9 +249,15 @@ export async function executeOperation({ }; let promise: Promise; if (fileVariables && fileVariables.length > 0) { + // the types expected by the shared constructMultipartForm expect + // variables to be defined or null + const requestBodyWithDefaultNullVariables = { + ...requestBody, + variables: requestBody.variables ?? null, + }; const form = await constructMultipartForm({ fileVariables, - requestBody, + requestBody: requestBodyWithDefaultNullVariables, }); promise = handleRequest(endpointUrl, { diff --git a/packages/explorer/src/helpers/readMultipartWebStream.ts b/packages/explorer/src/helpers/readMultipartWebStream.ts deleted file mode 100644 index e33cab6..0000000 --- a/packages/explorer/src/helpers/readMultipartWebStream.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { Observable } from 'zen-observable-ts'; -import type { GraphQLError } from 'graphql'; -import type MIMEType from 'whatwg-mimetype'; -import type { ResponseData } from './postMessageRelayHelpers'; - -export interface MultipartResponse { - data: ResponseData & { - incremental?: Array< - ResponseData & { path: NonNullable } - >; - error?: { message: string; stack?: string }; - hasNext?: boolean; - }; - headers?: Record | Record[]; - size: number; -} - -// https://apollographql.quip.com/mkWRAJfuxa7L/Multipart-subscriptions-protocol-spec -export interface MultipartSubscriptionResponse { - data: { - errors?: Array; - payload: - | (ResponseData & { - error?: { message: string; stack?: string }; - }) - | null; - }; - headers?: Record | Record[]; - size: number; - // True if --graphql-- message boundary is in the response - shouldTerminate?: boolean; -} - -export function readMultipartWebStream(response: Response, mimeType: MIMEType) { - if (response.body === null) { - throw new Error('Missing body'); - } else if (typeof response.body.tee !== 'function') { - // not sure if we actually need this check in explorer? - throw new Error( - 'Streaming bodies not supported by provided fetch implementation' - ); - } - - const decoder = new TextDecoder('utf-8'); - let buffer = ''; - - const messageBoundary = `--${mimeType.parameters.get('boundary') || '-'}`; - const subscriptionTerminationMessageBoundary = '--graphql--'; - - const reader = response.body.getReader(); - return { - closeReadableStream: () => reader.cancel(), - observable: new Observable< - MultipartResponse | MultipartSubscriptionResponse - >((observer) => { - function readMultipartStream() { - reader - .read() - .then((iteration) => { - if (iteration.done) { - observer.complete?.(); - return; - } - - const chunk = decoder.decode(iteration.value); - buffer += chunk; - - let boundaryIndex = buffer.indexOf(messageBoundary); - while (boundaryIndex > -1) { - const message = buffer.slice(0, boundaryIndex); - buffer = buffer.slice(boundaryIndex + messageBoundary.length); - - if (message.trim()) { - const messageStartIndex = message.indexOf('\r\n\r\n'); - - const chunkHeaders = Object.fromEntries( - message - .slice(0, messageStartIndex) - .split('\n') - .map((line) => { - const i = line.indexOf(':'); - if (i > -1) { - const name = line.slice(0, i).trim(); - const value = line.slice(i + 1).trim(); - return [name, value] as const; - } else { - return null; - } - }) - .filter((h): h is NonNullable => !!h) - ); - - if ( - chunkHeaders['content-type'] - ?.toLowerCase() - .indexOf('application/json') === -1 - ) { - throw new Error('Unsupported patch content type'); - } - - const bodyText = message.slice(messageStartIndex); - try { - observer.next?.({ - data: JSON.parse(bodyText), - headers: chunkHeaders, - size: chunk.length, - ...(chunk.indexOf(subscriptionTerminationMessageBoundary) > - -1 - ? { shouldTerminate: true } - : {}), - }); - } catch (err) { - // const parseError = err as ServerParseError; - // parseError.name = 'ServerParseError'; - // parseError.response = response; - // parseError.statusCode = response.status; - // parseError.bodyText = bodyText; - throw err; - } - } - - boundaryIndex = buffer.indexOf(messageBoundary); - } - - readMultipartStream(); - }) - .catch((err) => { - if (err.name === 'AbortError') return; - // if it is a network error, BUT there is graphql result info fire - // the next observer before calling error this gives apollo-client - // (and react-apollo) the `graphqlErrors` and `networkErrors` to - // pass to UI this should only happen if we *also* have data as - // part of the response key per the spec - if (err.result && err.result.errors && err.result.data) { - // if we don't call next, the UI can only show networkError - // because AC didn't get any graphqlErrors this is graphql - // execution result info (i.e errors and possibly data) this is - // because there is no formal spec how errors should translate to - // http status codes. So an auth error (401) could have both data - // from a public field, errors from a private field, and a status - // of 401 - // { - // user { // this will have errors - // firstName - // } - // products { // this is public so will have data - // cost - // } - // } - // - // the result of above *could* look like this: - // { - // data: { products: [{ cost: "$10" }] }, - // errors: [{ - // message: 'your session has timed out', - // path: [] - // }] - // } - // status code of above would be a 401 - // in the UI you want to show data where you can, errors as data where you can - // and use correct http status codes - observer.next?.({ - data: err.result, - size: Infinity, - }); - } - - observer.error?.(err); - }); - } - readMultipartStream(); - }), - }; -} diff --git a/packages/sandbox/package.json b/packages/sandbox/package.json index 1c75814..c4ad22f 100644 --- a/packages/sandbox/package.json +++ b/packages/sandbox/package.json @@ -65,6 +65,7 @@ } }, "dependencies": { + "@apollo/explorer-helpers": "^0.1.4", "@types/whatwg-mimetype": "^3.0.0", "eventemitter3": "3.1.0", "graphql-ws": "^5.9.0", diff --git a/packages/sandbox/src/helpers/constructMultipartForm.ts b/packages/sandbox/src/helpers/constructMultipartForm.ts deleted file mode 100644 index f017b70..0000000 --- a/packages/sandbox/src/helpers/constructMultipartForm.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { JSONValue } from './types'; - -export type FileVariable = { - variableKey: string; - files: { arrayBuffer: ArrayBuffer; fileName: string }[]; - isMultiFile: boolean; -}; - -// https://github.com/apollographql/apollo-client/blob/cbcf951256b22553bdb065dfa0d32c0a4ca804d3/src/link/http/serializeFetchParameter.ts -export const serializeFetchParameter = (p: any, label: string) => { - let serialized; - try { - serialized = JSON.stringify(p); - } catch (e) { - const parseError = new Error( - `Network request failed. ${label} is not serializable` - ); - throw parseError; - } - return serialized; -}; - -export const constructMultipartForm = async ({ - fileVariables: inputtedFileVariables, - requestBody, -}: { - fileVariables: FileVariable[]; - requestBody: { - operationName?: string; - query: string; - variables?: Record; - }; -}) => { - const fileVariables: { - variableKey: string; - files: File[]; - isMultiFile: boolean; - }[] = inputtedFileVariables.map((fileVariable) => ({ - ...fileVariable, - files: fileVariable.files.map( - ({ arrayBuffer, fileName }) => - new File([new Blob([arrayBuffer])], fileName) - ), - })); - - // the map element of a FormData maps indices to a single item array of variable names - // as seen here https://github.com/jaydenseric/graphql-multipart-request-spec#file-list - const map: Record = {}; - let i = 0; - // map must be the first thing in the form, followed by the files - // other wise you get the error: - // Misordered multipart fields; files should follow ‘map’ (https://github.com/jaydenseric/graphql-multipart-request-spec). - const filesToAppend: [string, File, string][] = []; - // variables are added to the operation body with null values, the variable - // name is used to match them to files uploaded in the later part of the request - // according to the spec https://github.com/jaydenseric/graphql-multipart-request-spec - let variablesWithNullsForFiles: - | Record - | undefined = requestBody.variables; - fileVariables.forEach( - ({ files, variableKey, isMultiFile }, fileVariableIndex) => { - if (files?.length) { - variablesWithNullsForFiles = { - ...variablesWithNullsForFiles, - [variableKey]: isMultiFile - ? new Array(files.length).fill(null) - : null, - }; - Array.from(files).forEach((file) => { - map[i] = [ - `${ - fileVariables.length > 1 ? `${fileVariableIndex}.` : '' - }variables.${variableKey}${isMultiFile ? `.${i}` : ''}`, - ]; - // in the request, there is expected to be a number appended that corresponds to each file - // https://github.com/jaydenseric/graphql-multipart-request-spec#file-list - filesToAppend.push([i.toString(), file, file.name]); - i++; - }); - } - } - ); - const form = new FormData(); - form.append( - 'operations', - serializeFetchParameter( - { - query: requestBody.query, - operationName: requestBody.operationName, - variables: variablesWithNullsForFiles, - }, - 'Payload' - ) - ); - - form.append('map', JSON.stringify(map)); - filesToAppend.forEach((item) => { - form.append(item[0], item[1], item[2]); - }); - - return form; -}; diff --git a/packages/sandbox/src/helpers/postMessageRelayHelpers.ts b/packages/sandbox/src/helpers/postMessageRelayHelpers.ts index 8bbba65..4e94ab5 100644 --- a/packages/sandbox/src/helpers/postMessageRelayHelpers.ts +++ b/packages/sandbox/src/helpers/postMessageRelayHelpers.ts @@ -25,14 +25,17 @@ import { INTROSPECTION_QUERY_WITH_HEADERS, } from './constants'; import MIMEType from 'whatwg-mimetype'; -import { readMultipartWebStream } from './readMultipartWebStream'; import type { JSONObject, JSONValue } from './types'; import type { ObjMap } from 'graphql/jsutils/ObjMap'; import type { GraphQLSubscriptionLibrary, HTTPMultipartClient, } from './subscriptionPostMessageRelayHelpers'; -import { constructMultipartForm, FileVariable } from './constructMultipartForm'; +import { + readMultipartWebStream, + constructMultipartForm, + FileVariable, +} from '@apollo/explorer-helpers'; export type HandleRequest = ( endpointUrl: string, @@ -91,21 +94,6 @@ type ExplorerResponse = ResponseData & { size?: number; }; -// https://apollographql.quip.com/mkWRAJfuxa7L/Multipart-subscriptions-protocol-spec -export interface MultipartSubscriptionResponse { - data: { - errors?: Array; - payload: - | (ResponseData & { - error?: { message: string; stack?: string }; - }) - | null; - }; - headers?: Record | Record[]; - size: number; - status?: number; -} - export type ExplorerSubscriptionResponse = // websocket response | { @@ -114,7 +102,20 @@ export type ExplorerSubscriptionResponse = errors?: GraphQLError[]; } // http multipart response options below - | MultipartSubscriptionResponse + // https://apollographql.quip.com/mkWRAJfuxa7L/Multipart-subscriptions-protocol-spec + | { + data: { + errors?: Array; + payload: + | (ResponseData & { + error?: { message: string; stack?: string }; + }) + | null; + }; + headers?: Record | Record[]; + size: number; + status?: number; + } | { data: null; // this only exists in the PM MultipartSubscriptionResponse @@ -266,9 +267,15 @@ export async function executeOperation({ }; let promise: Promise; if (fileVariables && fileVariables.length > 0) { + // the types expected by the shared constructMultipartForm expect + // variables to be defined or null + const requestBodyWithDefaultNullVariables = { + ...requestBody, + variables: requestBody.variables ?? null, + }; const form = await constructMultipartForm({ fileVariables, - requestBody, + requestBody: requestBodyWithDefaultNullVariables, }); promise = handleRequest(endpointUrl, { diff --git a/packages/sandbox/src/helpers/readMultipartWebStream.ts b/packages/sandbox/src/helpers/readMultipartWebStream.ts deleted file mode 100644 index e33cab6..0000000 --- a/packages/sandbox/src/helpers/readMultipartWebStream.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { Observable } from 'zen-observable-ts'; -import type { GraphQLError } from 'graphql'; -import type MIMEType from 'whatwg-mimetype'; -import type { ResponseData } from './postMessageRelayHelpers'; - -export interface MultipartResponse { - data: ResponseData & { - incremental?: Array< - ResponseData & { path: NonNullable } - >; - error?: { message: string; stack?: string }; - hasNext?: boolean; - }; - headers?: Record | Record[]; - size: number; -} - -// https://apollographql.quip.com/mkWRAJfuxa7L/Multipart-subscriptions-protocol-spec -export interface MultipartSubscriptionResponse { - data: { - errors?: Array; - payload: - | (ResponseData & { - error?: { message: string; stack?: string }; - }) - | null; - }; - headers?: Record | Record[]; - size: number; - // True if --graphql-- message boundary is in the response - shouldTerminate?: boolean; -} - -export function readMultipartWebStream(response: Response, mimeType: MIMEType) { - if (response.body === null) { - throw new Error('Missing body'); - } else if (typeof response.body.tee !== 'function') { - // not sure if we actually need this check in explorer? - throw new Error( - 'Streaming bodies not supported by provided fetch implementation' - ); - } - - const decoder = new TextDecoder('utf-8'); - let buffer = ''; - - const messageBoundary = `--${mimeType.parameters.get('boundary') || '-'}`; - const subscriptionTerminationMessageBoundary = '--graphql--'; - - const reader = response.body.getReader(); - return { - closeReadableStream: () => reader.cancel(), - observable: new Observable< - MultipartResponse | MultipartSubscriptionResponse - >((observer) => { - function readMultipartStream() { - reader - .read() - .then((iteration) => { - if (iteration.done) { - observer.complete?.(); - return; - } - - const chunk = decoder.decode(iteration.value); - buffer += chunk; - - let boundaryIndex = buffer.indexOf(messageBoundary); - while (boundaryIndex > -1) { - const message = buffer.slice(0, boundaryIndex); - buffer = buffer.slice(boundaryIndex + messageBoundary.length); - - if (message.trim()) { - const messageStartIndex = message.indexOf('\r\n\r\n'); - - const chunkHeaders = Object.fromEntries( - message - .slice(0, messageStartIndex) - .split('\n') - .map((line) => { - const i = line.indexOf(':'); - if (i > -1) { - const name = line.slice(0, i).trim(); - const value = line.slice(i + 1).trim(); - return [name, value] as const; - } else { - return null; - } - }) - .filter((h): h is NonNullable => !!h) - ); - - if ( - chunkHeaders['content-type'] - ?.toLowerCase() - .indexOf('application/json') === -1 - ) { - throw new Error('Unsupported patch content type'); - } - - const bodyText = message.slice(messageStartIndex); - try { - observer.next?.({ - data: JSON.parse(bodyText), - headers: chunkHeaders, - size: chunk.length, - ...(chunk.indexOf(subscriptionTerminationMessageBoundary) > - -1 - ? { shouldTerminate: true } - : {}), - }); - } catch (err) { - // const parseError = err as ServerParseError; - // parseError.name = 'ServerParseError'; - // parseError.response = response; - // parseError.statusCode = response.status; - // parseError.bodyText = bodyText; - throw err; - } - } - - boundaryIndex = buffer.indexOf(messageBoundary); - } - - readMultipartStream(); - }) - .catch((err) => { - if (err.name === 'AbortError') return; - // if it is a network error, BUT there is graphql result info fire - // the next observer before calling error this gives apollo-client - // (and react-apollo) the `graphqlErrors` and `networkErrors` to - // pass to UI this should only happen if we *also* have data as - // part of the response key per the spec - if (err.result && err.result.errors && err.result.data) { - // if we don't call next, the UI can only show networkError - // because AC didn't get any graphqlErrors this is graphql - // execution result info (i.e errors and possibly data) this is - // because there is no formal spec how errors should translate to - // http status codes. So an auth error (401) could have both data - // from a public field, errors from a private field, and a status - // of 401 - // { - // user { // this will have errors - // firstName - // } - // products { // this is public so will have data - // cost - // } - // } - // - // the result of above *could* look like this: - // { - // data: { products: [{ cost: "$10" }] }, - // errors: [{ - // message: 'your session has timed out', - // path: [] - // }] - // } - // status code of above would be a 401 - // in the UI you want to show data where you can, errors as data where you can - // and use correct http status codes - observer.next?.({ - data: err.result, - size: Infinity, - }); - } - - observer.error?.(err); - }); - } - readMultipartStream(); - }), - }; -}