Skip to content

Commit

Permalink
feat(url-loader): support application/graphql-response+json (#4703)
Browse files Browse the repository at this point in the history
* feat(url-loader): support application/graphql-response+json

* fix

* Drop live query dependency

* More changeset

* chore(dependencies): updated changesets for modified dependencies

* More improvements

* Add deprecated options back for backwards compat

* Fix tests

* chore(dependencies): updated changesets for modified dependencies

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
  • Loading branch information
ardatan and github-actions[bot] committed Sep 9, 2022
1 parent 1b0988a commit dd8886d
Show file tree
Hide file tree
Showing 12 changed files with 182 additions and 85 deletions.
7 changes: 7 additions & 0 deletions .changeset/@graphql-tools_url-loader-4703-dependencies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@graphql-tools/url-loader": patch
---

dependencies updates:

- Removed dependency [`@n1ru4l/graphql-live-query@^0.10.0` ↗︎](https://www.npmjs.com/package/@n1ru4l/graphql-live-query/v/null) (from `dependencies`)
5 changes: 5 additions & 0 deletions .changeset/cyan-dryers-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@graphql-tools/url-loader': patch
---

`multipart` and `graphqlSSEOptions` options have been removed. Multipart request will be done only if variables have extractable file or blob objects
5 changes: 5 additions & 0 deletions .changeset/shiny-impalas-fly.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@graphql-tools/url-loader': minor
---

Support application/graphql-response+json per GraphQL over HTTP spec
5 changes: 5 additions & 0 deletions .changeset/silly-crews-live.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@graphql-tools/url-loader': minor
---

URL Loader no longer throws but returns an execution result with errors
5 changes: 5 additions & 0 deletions .changeset/sour-mugs-crash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@graphql-tools/wrap': minor
---

Better error handling for introspectSchema
1 change: 0 additions & 1 deletion packages/loaders/url/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,6 @@
"@graphql-tools/utils": "8.11.0",
"@graphql-tools/wrap": "9.0.6",
"@ardatan/sync-fetch": "0.0.1",
"@n1ru4l/graphql-live-query": "^0.10.0",
"@types/ws": "^8.0.0",
"@whatwg-node/fetch": "^0.4.0",
"dset": "^3.1.2",
Expand Down
155 changes: 87 additions & 68 deletions packages/loaders/url/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
GraphQLError,
buildASTSchema,
buildSchema,
OperationDefinitionNode,
} from 'graphql';

import {
Expand All @@ -23,20 +22,20 @@ import {
parseGraphQLSDL,
getOperationASTFromRequest,
Observer,
createGraphQLError,
} from '@graphql-tools/utils';
import { introspectSchema, wrapSchema } from '@graphql-tools/wrap';
import { ClientOptions, createClient } from 'graphql-ws';
import WebSocket from 'isomorphic-ws';
import { extractFiles, isExtractableFile } from 'extract-files';
import { ValueOrPromise } from 'value-or-promise';
import { isLiveQueryOperationDefinitionNode } from '@n1ru4l/graphql-live-query';
import { AsyncFetchFn, defaultAsyncFetch } from './defaultAsyncFetch.js';
import { defaultSyncFetch, SyncFetchFn } from './defaultSyncFetch.js';
import { handleMultipartMixedResponse } from './handleMultipartMixedResponse.js';
import { handleEventStreamResponse } from './event-stream/handleEventStreamResponse.js';
import { cancelNeeded } from './event-stream/addCancelToResponseStream.js';
import { AbortController, FormData, File } from '@whatwg-node/fetch';
import { isBlob, isGraphQLUpload, isPromiseLike, LEGACY_WS } from './utils.js';
import { isBlob, isGraphQLUpload, isLiveQueryOperationDefinitionNode, isPromiseLike, LEGACY_WS } from './utils.js';

export type FetchFn = AsyncFetchFn | SyncFetchFn;

Expand Down Expand Up @@ -101,10 +100,6 @@ export interface LoadFromUrlOptions extends BaseLoaderOptions, Partial<Introspec
* Whether to use the GET HTTP method for queries when querying the original schema
*/
useGETForQueries?: boolean;
/**
* Use multipart for POST requests
*/
multipart?: boolean;
/**
* Handle URL as schema SDL
*/
Expand All @@ -121,10 +116,6 @@ export interface LoadFromUrlOptions extends BaseLoaderOptions, Partial<Introspec
* Use specific protocol for subscriptions
*/
subscriptionsProtocol?: SubscriptionProtocol;
/**
* @deprecated This is no longer used. Will be removed in the next release
*/
graphqlSseOptions?: any;
/**
* Retry attempts
*/
Expand All @@ -146,6 +137,17 @@ export interface LoadFromUrlOptions extends BaseLoaderOptions, Partial<Introspec
* Enable Batching
*/
batch?: boolean;

// Deprecated options

/**
* @deprecated This is no longer used. Will be removed in the next release
*/
graphqlSseOptions?: any;
/**
* @deprecated This is no longer used. Will be removed in the next release
*/
multipart?: boolean;
}

function isCompatibleUri(uri: string): boolean {
Expand Down Expand Up @@ -322,16 +324,10 @@ export class UrlLoader implements Loader<LoadFromUrlOptions> {
method = 'GET';
}

let accept = 'application/json';
let accept = 'application/graphql-response+json, application/json, multipart/mixed';
if (operationType === 'subscription' || isLiveQueryOperationDefinitionNode(operationAst)) {
method = 'GET';
accept = 'text/event-stream';
} else if (
(operationAst as OperationDefinitionNode).directives?.some(
({ name }) => name.value === 'defer' || name.value === 'stream'
)
) {
accept += ', multipart/mixed';
}

const endpoint = request.extensions?.endpoint || HTTP_URL;
Expand Down Expand Up @@ -379,44 +375,26 @@ export class UrlLoader implements Loader<LoadFromUrlOptions> {
request.info
);
case 'POST':
if (options?.multipart) {
return new ValueOrPromise(() => this.createFormDataFromVariables(requestBody))
.then(
body =>
fetch(
endpoint,
{
method: 'POST',
...(options?.credentials != null ? { credentials: options.credentials } : {}),
body,
headers: {
...headers,
...(typeof body === 'string' ? { 'content-type': 'application/json' } : {}),
},
signal: controller?.signal,
return new ValueOrPromise(() => this.createFormDataFromVariables(requestBody))
.then(
body =>
fetch(
endpoint,
{
method: 'POST',
...(options?.credentials != null ? { credentials: options.credentials } : {}),
body,
headers: {
...headers,
...(typeof body === 'string' ? { 'content-type': 'application/json' } : {}),
},
request.context,
request.info
) as any
)
.resolve();
} else {
return fetch(
endpoint,
{
method: 'POST',
...(options?.credentials != null ? { credentials: options.credentials } : {}),
body: JSON.stringify(requestBody),
headers: {
'content-type': 'application/json',
...headers,
},
signal: controller?.signal,
},
request.context,
request.info
);
}
signal: controller?.signal,
},
request.context,
request.info
) as any
)
.resolve();
}
})
.then((fetchResult: Response): any => {
Expand Down Expand Up @@ -448,24 +426,72 @@ export class UrlLoader implements Loader<LoadFromUrlOptions> {
return result;
}
})
.catch((e: any) => {
if (typeof e === 'string') {
return {
errors: [
createGraphQLError(e, {
extensions: {
requestBody,
},
}),
],
};
} else if (e.name === 'GraphQLError') {
return {
errors: [e],
};
} else if (e.name === 'TypeError' && e.message === 'fetch failed') {
return {
errors: [
createGraphQLError(`fetch failed to ${endpoint}`, {
extensions: {
requestBody,
},
originalError: e,
}),
],
};
} else if (e.message) {
return {
errors: [
createGraphQLError(e.message, {
extensions: {
requestBody,
},
originalError: e,
}),
],
};
} else {
return {
errors: [
createGraphQLError('Unknown error', {
extensions: {
requestBody,
},
originalError: e,
}),
],
};
}
})
.resolve();
};

if (options?.retry != null) {
return function retryExecutor(request: ExecutionRequest) {
let result: ExecutionResult<any> | undefined;
let error: Error | undefined;
let attempt = 0;
function retryAttempt(): Promise<ExecutionResult<any>> | ExecutionResult<any> {
attempt++;
if (attempt > options!.retry!) {
if (result != null) {
return result;
}
if (error != null) {
throw error;
}
throw new Error('No result');
return {
errors: [createGraphQLError('No response returned from fetch')],
};
}
return new ValueOrPromise(() => executor(request))
.then(res => {
Expand All @@ -475,10 +501,6 @@ export class UrlLoader implements Loader<LoadFromUrlOptions> {
}
return result;
})
.catch((e: any) => {
error = e;
return retryAttempt();
})
.resolve();
}
return retryAttempt();
Expand Down Expand Up @@ -771,10 +793,7 @@ export class UrlLoader implements Loader<LoadFromUrlOptions> {
// eslint-disable-next-line no-inner-declarations
function getExecutorByRequest(request: ExecutionRequest<any>): ValueOrPromise<Executor> {
const operationAst = getOperationASTFromRequest(request);
if (
operationAst.operation === 'subscription' ||
isLiveQueryOperationDefinitionNode(operationAst, request.variables as Record<string, any>)
) {
if (operationAst.operation === 'subscription' || isLiveQueryOperationDefinitionNode(operationAst)) {
return subscriptionExecutor$;
} else {
return httpExecutor$;
Expand Down
8 changes: 8 additions & 0 deletions packages/loaders/url/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { memoize1 } from '@graphql-tools/utils';
import { OperationDefinitionNode } from 'graphql';
import type { Readable } from 'stream';

export function isBlob(obj: any): obj is Blob {
Expand All @@ -18,6 +20,12 @@ export function isPromiseLike(obj: any): obj is PromiseLike<any> {
return typeof obj.then === 'function';
}

export const isLiveQueryOperationDefinitionNode = memoize1(function isLiveQueryOperationDefinitionNode(
node: OperationDefinitionNode
) {
return node.directives?.some(directive => directive.name.value === 'live');
});

export enum LEGACY_WS {
CONNECTION_INIT = 'connection_init',
CONNECTION_ACK = 'connection_ack',
Expand Down
4 changes: 1 addition & 3 deletions packages/loaders/url/tests/graphql-upload.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,7 @@ describe('GraphQL Upload compatibility', () => {
});
});

const [{ schema }] = await loader.load(`http://0.0.0.0:9871/graphql`, {
multipart: true,
});
const [{ schema }] = await loader.load(`http://0.0.0.0:9871/graphql`, {});

const fileName = 'testfile.txt';

Expand Down
2 changes: 1 addition & 1 deletion packages/loaders/url/tests/helix-yoga-compat.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ describe('helix/yoga compat', () => {
expect(data).toEqual(expectedDatas.shift()!);
}
expect(expectedDatas.length).toBe(0);
expect(receivedAcceptHeader).toBe('application/json');
expect(receivedAcceptHeader).toContain('multipart/mixed');
});

it('should handle SSE subscription result', async () => {
Expand Down

0 comments on commit dd8886d

Please sign in to comment.