Skip to content
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

feat: Multipart Subscriptions #10592

Merged
merged 33 commits into from
Mar 30, 2023
Merged
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
84b3a65
feat: send multipart/mixed in accept header for multipart-subscriptions
alessbell Feb 9, 2023
16fb132
chore: comment out unused import
alessbell Mar 17, 2023
e87721b
chore: bump bundlesize to 33kb (was 32.65kb)
alessbell Mar 17, 2023
4e0097a
chore: comment out unused test query
alessbell Mar 17, 2023
519982e
chore: do not call observer.next without data or errors
alessbell Mar 17, 2023
2b8f813
chore: update tests
alessbell Mar 22, 2023
debf60d
Merge branch 'main' into router-subs
alessbell Mar 22, 2023
f77e6c8
chore: remove whitespace from defer test
alessbell Mar 22, 2023
2ce04d2
Merge branch 'router-subs' of github.com:apollographql/apollo-client …
alessbell Mar 22, 2023
cba65b9
chore: remove test from useSubscription.test.tsx
alessbell Mar 22, 2023
e458d96
fix: rename interface to ApolloPayloadResult
alessbell Mar 27, 2023
daf0993
Update src/link/core/types.ts
alessbell Mar 27, 2023
2c77342
fix: add isApolloPayloadResult user-defined type guard
alessbell Mar 27, 2023
6014fbf
Merge branch 'router-subs' of github.com:apollographql/apollo-client …
alessbell Mar 27, 2023
c3a4b88
fix: call invariant.warn if defer used in multipart subscription
alessbell Mar 27, 2023
22674b1
Merge branch 'main' into router-subs
alessbell Mar 27, 2023
90ecc0a
Revert "Update src/link/core/types.ts"
alessbell Mar 27, 2023
09a2b39
fix: improves handling of transport-specific errors
alessbell Mar 28, 2023
9a8c5cb
fix: add ApolloError test, fix nits
alessbell Mar 28, 2023
99420e0
fix: use Symbol key to avoid naming collision within extensions
alessbell Mar 29, 2023
b552193
fix: remove duplicate error messages from snapshot
alessbell Mar 29, 2023
af56408
fix: update exports snapshot test
alessbell Mar 29, 2023
b8dfc11
Update src/link/http/createHttpLink.ts
alessbell Mar 29, 2023
ca2d678
Merge branch 'main' into router-subs
alessbell Mar 29, 2023
cc252d7
chore: bump bundlesize to 32.98kb (was 32.89kb)
alessbell Mar 29, 2023
8337eb0
chore: format protocolError messages passed to ApolloError
alessbell Mar 29, 2023
05222fd
chore: add comment about Symbol in src/errors/index.ts
alessbell Mar 29, 2023
3c7567f
fix: should handle both protocolErrors and graphQLErrors simultaneously
alessbell Mar 29, 2023
6ae538e
chore: bump bundlesize to 33.02kb (was 32.98kb)
alessbell Mar 29, 2023
e2a727f
chore: rename graphQLResultHasProtocolError > graphQLResultHasProtoco…
alessbell Mar 30, 2023
6106da4
fix: use ApolloErrorOptions to type graphQL/protocolErrors opts
alessbell Mar 30, 2023
8f6f693
fix: remove done from ApolloPayloadResult and add null to payload
alessbell Mar 30, 2023
97e4e60
Update src/utilities/common/incrementalResult.ts
alessbell Mar 30, 2023
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
5 changes: 5 additions & 0 deletions .changeset/nervous-tomatoes-act.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@apollo/client": patch
---

Adds support for multipart subscriptions in `HttpLink`.
2 changes: 1 addition & 1 deletion config/bundlesize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { join } from "path";
import { gzipSync } from "zlib";
import bytes from "bytes";

const gzipBundleByteLengthLimit = bytes("32.75KB");
const gzipBundleByteLengthLimit = bytes("33.02KB");
const minFile = join("dist", "apollo-client.min.cjs");
const minPath = join(__dirname, "..", minFile);
const gzipByteLen = gzipSync(readFileSync(minPath)).byteLength;
Expand Down
3 changes: 1 addition & 2 deletions config/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,14 @@ const react18TestFileIgnoreList = [
// to avoid running them twice with both react versions
// since they do not import react
ignoreTSFiles,
// failing hoc tests (8)
// failing hoc tests (7)
'src/react/hoc/__tests__/mutations/queries.test.tsx',
'src/react/hoc/__tests__/mutations/recycled-queries.test.tsx',
'src/react/hoc/__tests__/queries/errors.test.tsx',
'src/react/hoc/__tests__/queries/lifecycle.test.tsx',
'src/react/hoc/__tests__/queries/loading.test.tsx',
'src/react/hoc/__tests__/queries/observableQuery.test.tsx',
'src/react/hoc/__tests__/queries/skip.test.tsx',
'src/react/hoc/__tests__/subscriptions/subscriptions.test.tsx',
// failing components tests (1)
'src/react/components/__tests__/client/Query.test.tsx',
];
Expand Down
2 changes: 2 additions & 0 deletions src/__tests__/__snapshots__/exports.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ Array [
exports[`exports of public entry points @apollo/client/errors 1`] = `
Array [
"ApolloError",
"PROTOCOL_ERRORS_SYMBOL",
"graphQLResultHasProtocolErrors",
"isApolloError",
]
`;
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/__snapshots__/graphqlSubscriptions.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@

exports[`GraphQL Subscriptions should throw an error if the result has errors on it 1`] = `[ApolloError: This is an error]`;

exports[`GraphQL Subscriptions should throw an error if the result has errors on it 2`] = `[ApolloError: This is an error]`;
exports[`GraphQL Subscriptions should throw an error if the result has protocolErrors on it 1`] = `[ApolloError: cannot read message from websocket]`;
73 changes: 56 additions & 17 deletions src/__tests__/graphqlSubscriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import gql from 'graphql-tag';

import { ApolloClient } from '../core';
import { InMemoryCache } from '../cache';
import { PROTOCOL_ERRORS_SYMBOL } from '../errors';
import { QueryManager } from '../core/QueryManager';
import { itAsync, mockObservableLink } from '../testing';

Expand Down Expand Up @@ -187,22 +188,17 @@ describe('GraphQL Subscriptions', () => {

const obs = queryManager.startGraphQLSubscription(options);

const promises = [];
for (let i = 0; i < 2; i += 1) {
promises.push(
new Promise<void>((resolve, reject) => {
obs.subscribe({
next(result) {
reject('Should have hit the error block');
},
error(error) {
expect(error).toMatchSnapshot();
resolve();
},
});
}),
);
}
const promise = new Promise<void>((resolve, reject) => {
obs.subscribe({
next(result) {
reject('Should have hit the error block');
},
error(error) {
expect(error).toMatchSnapshot();
resolve();
},
});
});

const errorResult = {
result: {
Expand All @@ -223,7 +219,7 @@ describe('GraphQL Subscriptions', () => {
};

link.simulateResult(errorResult);
return Promise.all(promises);
return Promise.resolve(promise);
});

it('should call complete handler when the subscription completes', () => {
Expand Down Expand Up @@ -261,4 +257,47 @@ describe('GraphQL Subscriptions', () => {

link.simulateResult(results[0]);
});

it('should throw an error if the result has protocolErrors on it', () => {
const link = mockObservableLink();
const queryManager = new QueryManager({
link,
cache: new InMemoryCache({ addTypename: false }),
});

const obs = queryManager.startGraphQLSubscription(options);

const promise = new Promise<void>((resolve, reject) => {
obs.subscribe({
next(result) {
reject("Should have hit the error block");
},
error(error) {
expect(error).toMatchSnapshot();
resolve();
},
});
});

const errorResult = {
result: {
data: null,
extensions: {
[PROTOCOL_ERRORS_SYMBOL]: [
{
message: 'cannot read message from websocket',
extensions: [
{
code: "WEBSOCKET_MESSAGE_ERROR"
}
],
} as any,
],
}
},
};

link.simulateResult(errorResult);
return Promise.resolve(promise);
});
});
20 changes: 14 additions & 6 deletions src/core/QueryManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import {
isNonNullObject,
} from '../utilities';
import { mergeIncrementalData } from '../utilities/common/incrementalResult';
import { ApolloError, isApolloError } from '../errors';
import { ApolloError, isApolloError, graphQLResultHasProtocolErrors } from '../errors';
import {
QueryOptions,
WatchQueryOptions,
Expand Down Expand Up @@ -61,6 +61,7 @@ import {
shouldWriteResult,
CacheWriteBehavior,
} from './QueryInfo';
import { PROTOCOL_ERRORS_SYMBOL, ApolloErrorOptions } from '../errors';

const { hasOwnProperty } = Object.prototype;

Expand Down Expand Up @@ -936,10 +937,17 @@ export class QueryManager<TStore> {
this.broadcastQueries();
}

if (graphQLResultHasError(result)) {
throw new ApolloError({
graphQLErrors: result.errors,
});
const hasErrors = graphQLResultHasError(result);
const hasProtocolErrors = graphQLResultHasProtocolErrors(result);
if (hasErrors || hasProtocolErrors) {
const errors: ApolloErrorOptions = {};
if (hasErrors) {
errors.graphQLErrors = result.errors;
}
if (hasProtocolErrors) {
errors.protocolErrors = result.extensions[PROTOCOL_ERRORS_SYMBOL];
}
throw new ApolloError(errors);
}

return result;
Expand Down Expand Up @@ -1235,7 +1243,7 @@ export class QueryManager<TStore> {
setTimeout(() => concast.cancel(reason));
});

let concast: Concast<ApolloQueryResult<TData>>,
let concast: Concast<ApolloQueryResult<TData>>,
containsDataFromLink: boolean;
// If the query has @export(as: ...) directives, then we need to
// process those directives asynchronously. When there are no
Expand Down
34 changes: 34 additions & 0 deletions src/errors/__tests__/ApolloError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,26 @@ describe('ApolloError', () => {
new GraphQLError('Something went wrong with GraphQL'),
new GraphQLError('Something else went wrong with GraphQL'),
];
const protocolErrors = [
{
message: "cannot read message from websocket",
extensions: [
{
code: "WEBSOCKET_MESSAGE_ERROR",
},
],
}
];
const networkError = new Error('Network error');
const errorMessage = 'this is an error message';
const apolloError = new ApolloError({
graphQLErrors: graphQLErrors,
protocolErrors: protocolErrors,
networkError: networkError,
errorMessage: errorMessage,
});
expect(apolloError.graphQLErrors).toEqual(graphQLErrors);
expect(apolloError.protocolErrors).toEqual(protocolErrors);
expect(apolloError.networkError).toEqual(networkError);
expect(apolloError.message).toBe(errorMessage);
});
Expand Down Expand Up @@ -61,6 +73,28 @@ describe('ApolloError', () => {
expect(messages[1]).toMatch('network error message');
});

it('should add both protocol and graphql errors to the message', () => {
const graphQLErrors = [new GraphQLError('graphql error message')];
const protocolErrors = [
{
message: "cannot read message from websocket",
extensions: [
{
code: "WEBSOCKET_MESSAGE_ERROR",
},
],
}
];
const apolloError = new ApolloError({
graphQLErrors,
protocolErrors,
});
const messages = apolloError.message.split('\n');
expect(messages.length).toBe(2);
expect(messages[0]).toMatch('graphql error message');
expect(messages[1]).toMatch('cannot read message from websocket');
});

it('should contain a stack trace', () => {
const graphQLErrors = [new GraphQLError('graphql error message')];
const networkError = new Error('network error message');
Expand Down
61 changes: 51 additions & 10 deletions src/errors/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,46 @@
import '../utilities/globals';

import { GraphQLError } from 'graphql';
import { GraphQLError, GraphQLErrorExtensions } from 'graphql';

import { isNonEmptyArray } from '../utilities';
import { ServerParseError } from '../link/http';
import { ServerError } from '../link/utils';
import { FetchResult } from "../link/core";

// This Symbol allows us to pass transport-specific errors from the link chain
// into QueryManager/client internals without risking a naming collision within
// extensions (which implementers can use as they see fit).
export const PROTOCOL_ERRORS_SYMBOL: unique symbol = Symbol();

type FetchResultWithSymbolExtensions<T> = FetchResult<T> & {
extensions: Record<string | symbol, any>
};

export interface ApolloErrorOptions {
graphQLErrors?: ReadonlyArray<GraphQLError>;
protocolErrors?: ReadonlyArray<{
message: string;
extensions?: GraphQLErrorExtensions[];
}>;
clientErrors?: ReadonlyArray<Error>;
networkError?: Error | ServerParseError | ServerError | null;
errorMessage?: string;
extraInfo?: any;
}

export function graphQLResultHasProtocolErrors<T>(
result: FetchResult<T>
): result is FetchResultWithSymbolExtensions<T> {
if (result.extensions) {
return Array.isArray(
(result as FetchResultWithSymbolExtensions<T>).extensions[
PROTOCOL_ERRORS_SYMBOL
]
);
}
return false;
}


export function isApolloError(err: Error): err is ApolloError {
return err.hasOwnProperty('graphQLErrors');
Expand All @@ -17,9 +53,14 @@ export function isApolloError(err: Error): err is ApolloError {
const generateErrorMessage = (err: ApolloError) => {
let message = '';
// If we have GraphQL errors present, add that to the error message.
if (isNonEmptyArray(err.graphQLErrors) || isNonEmptyArray(err.clientErrors)) {
if (
isNonEmptyArray(err.graphQLErrors) ||
isNonEmptyArray(err.clientErrors) ||
isNonEmptyArray(err.protocolErrors)
) {
const errors = ((err.graphQLErrors || []) as readonly Error[])
.concat(err.clientErrors || []);
.concat(err.clientErrors || [])
.concat((err.protocolErrors || []) as readonly Error[]);
errors.forEach((error: Error) => {
const errorMessage = error
? error.message
Expand All @@ -45,6 +86,10 @@ export class ApolloError extends Error {
public name: string;
public message: string;
public graphQLErrors: GraphQLErrors;
public protocolErrors: ReadonlyArray<{
message: string;
extensions?: GraphQLErrorExtensions[];
}>;
public clientErrors: ReadonlyArray<Error>;
public networkError: Error | ServerParseError | ServerError | null;

Expand All @@ -58,20 +103,16 @@ export class ApolloError extends Error {
// value or the constructed error will be meaningless.
constructor({
graphQLErrors,
protocolErrors,
clientErrors,
networkError,
errorMessage,
extraInfo,
}: {
graphQLErrors?: ReadonlyArray<GraphQLError>;
clientErrors?: ReadonlyArray<Error>;
networkError?: Error | ServerParseError | ServerError | null;
errorMessage?: string;
extraInfo?: any;
}) {
}: ApolloErrorOptions) {
super(errorMessage);
this.name = 'ApolloError';
this.graphQLErrors = graphQLErrors || [];
this.protocolErrors = protocolErrors || [];
this.clientErrors = clientErrors || [];
this.networkError = networkError || null;
this.message = errorMessage || generateErrorMessage(this);
Expand Down
15 changes: 11 additions & 4 deletions src/link/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,7 @@ export interface ExecutionPatchInitialResult<
extensions?: TExtensions;
}

export interface IncrementalPayload<
TData,
TExtensions,
> {
export interface IncrementalPayload<TData, TExtensions> {
// data and path must both be present
// https://github.com/graphql/graphql-spec/pull/742/files#diff-98d0cd153b72b63c417ad4238e8cc0d3385691ccbde7f7674bc0d2a718b896ecR288-R293
data: TData | null;
Expand All @@ -48,6 +45,16 @@ export interface ExecutionPatchIncrementalResult<
extensions?: never;
}

export interface ApolloPayloadResult<
TData = Record<string, any>,
TExtensions = Record<string, any>
> {
payload: SingleExecutionResult | ExecutionPatchResult | null;
// Transport layer errors (as distinct from GraphQL or NetworkErrors),
// these are fatal errors that will include done: true.
errors?: ReadonlyArray<Error | string>;
}

export type ExecutionPatchResult<
TData = Record<string, any>,
TExtensions = Record<string, any>
Expand Down
Loading