Skip to content

Commit

Permalink
Automatically strip __typename for input data in variables (#10724)
Browse files Browse the repository at this point in the history
  • Loading branch information
jerelmiller committed Apr 13, 2023
1 parent 9def742 commit e285dfd
Show file tree
Hide file tree
Showing 15 changed files with 612 additions and 5 deletions.
5 changes: 5 additions & 0 deletions .changeset/cyan-insects-love.md
@@ -0,0 +1,5 @@
---
'@apollo/client': patch
---

Automatically strips `__typename` fields from `variables` sent to the server when using [`HttpLink`](https://www.apollographql.com/docs/react/api/link/apollo-link-http), [`BatchHttpLink`](https://www.apollographql.com/docs/react/api/link/apollo-link-batch-http), or [`GraphQLWsLink`](https://www.apollographql.com/docs/react/api/link/apollo-link-subscriptions). This allows GraphQL data returned from a query to be used as an argument to a subsequent GraphQL operation without the need to strip the `__typename` in user-space.
11 changes: 11 additions & 0 deletions .prettierignore
Expand Up @@ -36,6 +36,17 @@ src/react/*
!src/utilities/
src/utilities/*
!src/utilities/promises/
!src/utilities/types/
src/utilities/types/*
!src/utilities/types/DeepOmit.ts
!src/utilities/common
src/utilities/common/*
!src/utilities/common/stripTypename.ts
!src/utilities/common/omitDeep.ts
!src/utilities/common/__tests__/
src/utilities/common/__tests__/*
!src/utilities/common/__tests__/omitDeep.ts
!src/utilities/common/__tests__/stripTypename.ts

## Allowed React Hooks
!src/react/hooks/
Expand Down
2 changes: 1 addition & 1 deletion config/bundlesize.ts
Expand Up @@ -3,7 +3,7 @@ import { join } from "path";
import { gzipSync } from "zlib";
import bytes from "bytes";

const gzipBundleByteLengthLimit = bytes("34.83KB");
const gzipBundleByteLengthLimit = bytes("34.98KB");
const minFile = join("dist", "apollo-client.min.cjs");
const minPath = join(__dirname, "..", minFile);
const gzipByteLen = gzipSync(readFileSync(minPath)).byteLength;
Expand Down
3 changes: 3 additions & 0 deletions src/__tests__/__snapshots__/exports.ts.snap
Expand Up @@ -409,6 +409,7 @@ Array [
"isNodeResponse",
"isNonEmptyArray",
"isNonNullObject",
"isPlainObject",
"isReadableStream",
"isReference",
"isStatefulPromise",
Expand All @@ -423,6 +424,7 @@ Array [
"mergeIncrementalData",
"mergeOptions",
"offsetLimitPagination",
"omitDeep",
"relayStylePagination",
"removeArgumentsFromDocument",
"removeClientSetsFromDocument",
Expand All @@ -433,6 +435,7 @@ Array [
"shouldInclude",
"storeKeyNameFromField",
"stringifyForDisplay",
"stripTypename",
"valueToObjectRepresentation",
"wrapPromiseWithState",
]
Expand Down
258 changes: 257 additions & 1 deletion src/link/http/__tests__/HttpLink.ts
Expand Up @@ -1110,7 +1110,263 @@ describe('HttpLink', () => {
expect(errorHandler).toHaveBeenCalledWith(
new Error('HttpLink: Trying to send a client-only query to the server. To send to the server, ensure a non-client field is added to the query or set the `transformOptions.removeClientFields` option to `true`.')
);
})
});

it('strips __typename from object argument when sending a mutation', async () => {
fetchMock.mock('https://example.com/graphql', {
status: 200,
body: JSON.stringify({
data: {
__typename: 'Mutation',
updateTodo: {
__typename: 'Todo',
id: 1,
name: 'Take out trash',
completed: true
}
}
}),
headers: { 'content-type': 'application/json' }
});

const query = gql`
mutation UpdateTodo($todo: TodoInput!) {
updateTodo(todo: $todo) {
id
name
completed
}
}
`;

const link = createHttpLink({ uri: 'https://example.com/graphql' });

const todo = {
__typename: 'Todo',
id: 1,
name: 'Take out trash',
completed: true,
}

await new Promise((resolve, reject) => {
execute(link, { query, variables: { todo } }).subscribe({
next: resolve,
error: reject
});
});

const [, options] = fetchMock.lastCall()!;
const { body } = options!

expect(JSON.parse(body!.toString())).toEqual({
operationName: 'UpdateTodo',
query: print(query),
variables: {
todo: {
id: 1,
name: 'Take out trash',
completed: true,
}
}
});
});

it('strips __typename from array argument when sending a mutation', async () => {
fetchMock.mock('https://example.com/graphql', {
status: 200,
body: JSON.stringify({
data: {
__typename: 'Mutation',
updateTodos: [
{
__typename: 'Todo',
id: 1,
name: 'Take out trash',
completed: true
},
{
__typename: 'Todo',
id: 2,
name: 'Clean room',
completed: true
},
]
}
}),
headers: { 'content-type': 'application/json' }
});

const query = gql`
mutation UpdateTodos($todos: [TodoInput!]!) {
updateTodos(todos: $todos) {
id
name
completed
}
}
`;

const link = createHttpLink({ uri: 'https://example.com/graphql' });

const todos = [
{
__typename: 'Todo',
id: 1,
name: 'Take out trash',
completed: true,
},
{
__typename: 'Todo',
id: 2,
name: 'Clean room',
completed: true,
},
];

await new Promise((resolve, reject) => {
execute(link, { query, variables: { todos } }).subscribe({
next: resolve,
error: reject
});
});

const [, options] = fetchMock.lastCall()!;
const { body } = options!

expect(JSON.parse(body!.toString())).toEqual({
operationName: 'UpdateTodos',
query: print(query),
variables: {
todos: [
{
id: 1,
name: 'Take out trash',
completed: true,
},
{
id: 2,
name: 'Clean room',
completed: true,
},
]
}
});
});

it('strips __typename from mixed argument when sending a mutation', async () => {
fetchMock.mock('https://example.com/graphql', {
status: 200,
body: JSON.stringify({
data: {
__typename: 'Mutation',
updateProfile: {
__typename: 'Profile',
id: 1,
},
}
}),
headers: { 'content-type': 'application/json' }
});

const query = gql`
mutation UpdateProfile($profile: ProfileInput!) {
updateProfile(profile: $profile) {
id
}
}
`;

const link = createHttpLink({ uri: 'https://example.com/graphql' });

const profile = {
__typename: 'Profile',
id: 1,
interests: [
{ __typename: 'Interest', name: 'Hiking' },
{ __typename: 'Interest', name: 'Nature' }
],
avatar: {
__typename: 'Avatar',
url: 'https://example.com/avatar.jpg',
}
};

await new Promise((resolve, reject) => {
execute(link, { query, variables: { profile } }).subscribe({
next: resolve,
error: reject
});
});

const [, options] = fetchMock.lastCall()!;
const { body } = options!

expect(JSON.parse(body!.toString())).toEqual({
operationName: 'UpdateProfile',
query: print(query),
variables: {
profile: {
id: 1,
interests: [
{ name: 'Hiking' },
{ name: 'Nature' }
],
avatar: {
url: 'https://example.com/avatar.jpg',
},
},
}
});
});
});

it('strips __typename when sending a query', async () => {
fetchMock.mock('https://example.com/graphql', {
status: 200,
body: JSON.stringify({
data: {
__typename: 'Query',
searchTodos: []
}
}),
headers: { 'content-type': 'application/json' }
});

const query = gql`
query SearchTodos($filter: TodoFilter!) {
searchTodos(filter: $filter) {
id
name
}
}
`;

const link = createHttpLink({ uri: 'https://example.com/graphql' });

const filter = {
__typename: 'Filter',
completed: true,
};

await new Promise((resolve, reject) => {
execute(link, { query, variables: { filter } }).subscribe({
next: resolve,
error: reject
});
});

const [, options] = fetchMock.lastCall()!;
const { body } = options!

expect(JSON.parse(body!.toString())).toEqual({
operationName: 'SearchTodos',
query: print(query),
variables: {
filter: {
completed: true,
},
},
});
});

describe('Dev warnings', () => {
Expand Down
21 changes: 21 additions & 0 deletions src/link/http/__tests__/selectHttpOptionsAndBody.ts
Expand Up @@ -104,4 +104,25 @@ describe('selectHttpOptionsAndBody', () => {

expect(body.query).toBe('query SampleQuery{stub{id}}');
});

it('strips __typename from variables', () => {
const operation = createOperation(
{},
{
query,
variables: {
__typename: 'Test',
nested: { __typename: 'Nested', foo: 'bar' },
array: [{ __typename: 'Item', baz: 'foo' }]
},
}
);

const { body } = selectHttpOptionsAndBody(operation, {});

expect(body.variables).toEqual({
nested: { foo: 'bar' },
array: [{ baz: 'foo' }],
});
})
});
3 changes: 2 additions & 1 deletion src/link/http/selectHttpOptionsAndBody.ts
@@ -1,4 +1,5 @@
import { ASTNode, print } from 'graphql';
import { stripTypename } from '../../utilities';

import { Operation } from '../core';

Expand Down Expand Up @@ -179,7 +180,7 @@ export function selectHttpOptionsAndBodyInternal(

//The body depends on the http options
const { operationName, extensions, variables, query } = operation;
const body: Body = { operationName, variables };
const body: Body = { operationName, variables: stripTypename(variables) };

if (http.includeExtensions) (body as any).extensions = extensions;

Expand Down
8 changes: 6 additions & 2 deletions src/link/subscriptions/index.ts
Expand Up @@ -32,7 +32,7 @@ import { print } from "graphql";
import type { Client } from "graphql-ws";

import { ApolloLink, Operation, FetchResult } from "../core";
import { isNonNullObject, Observable } from "../../utilities";
import { isNonNullObject, stripTypename, Observable } from "../../utilities";
import { ApolloError } from "../../errors";

// https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close_event
Expand All @@ -53,7 +53,11 @@ export class GraphQLWsLink extends ApolloLink {
public request(operation: Operation): Observable<FetchResult> {
return new Observable((observer) => {
return this.client.subscribe<FetchResult>(
{ ...operation, query: print(operation.query) },
{
...operation,
query: print(operation.query),
variables: stripTypename(operation.variables)
},
{
next: observer.next.bind(observer),
complete: observer.complete.bind(observer),
Expand Down

0 comments on commit e285dfd

Please sign in to comment.