Skip to content

Commit fa64fbf

Browse files
authored
Keep fields specified by @unmask for use with Apollo Client data masking (#10163)
* Keep fields specified by @unmask * Update documentation for inlineFragmentTypes to mention mask option * Require apolloUnmask config to handle unmasking * Add changeset * Update packages changed in changeset * Fix prettier issue
1 parent 1617e3c commit fa64fbf

File tree

6 files changed

+216
-2
lines changed

6 files changed

+216
-2
lines changed

.changeset/twelve-windows-give.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@graphql-codegen/visitor-plugin-common': minor
3+
'@graphql-codegen/typescript-operations': minor
4+
---
5+
6+
Add support for Apollo Client `@unmask` directive with fragment masking.

packages/plugins/other/visitor-plugin-common/src/base-documents-visitor.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { ParsedTypesConfig, RawTypesConfig } from './base-types-visitor.js';
1111
import { BaseVisitor } from './base-visitor.js';
1212
import { DEFAULT_SCALARS } from './scalars.js';
1313
import { SelectionSetToObject } from './selection-set-to-object.js';
14-
import { NormalizedScalarsMap } from './types.js';
14+
import { NormalizedScalarsMap, CustomDirectivesConfig } from './types.js';
1515
import { buildScalarsFromConfig, DeclarationBlock, DeclarationBlockConfig, getConfigValue } from './utils.js';
1616
import { OperationVariablesToObject } from './variables-to-object.js';
1717

@@ -40,6 +40,7 @@ export interface ParsedDocumentsConfig extends ParsedTypesConfig {
4040
skipTypeNameForRoot: boolean;
4141
experimentalFragmentVariables: boolean;
4242
mergeFragmentTypes: boolean;
43+
customDirectives: CustomDirectivesConfig;
4344
}
4445

4546
export interface RawDocumentsConfig extends RawTypesConfig {
@@ -149,6 +150,30 @@ export interface RawDocumentsConfig extends RawTypesConfig {
149150
* @ignore
150151
*/
151152
namespacedImportName?: string;
153+
154+
/**
155+
* @description Configures behavior for use with custom directives from
156+
* various GraphQL libraries.
157+
* @exampleMarkdown
158+
* ```ts filename="codegen.ts"
159+
* import type { CodegenConfig } from '@graphql-codegen/cli';
160+
*
161+
* const config: CodegenConfig = {
162+
* // ...
163+
* generates: {
164+
* 'path/to/file.ts': {
165+
* plugins: ['typescript'],
166+
* config: {
167+
* customDirectives: {
168+
* apolloUnmask: true
169+
* }
170+
* },
171+
* },
172+
* },
173+
* };
174+
* export default config;
175+
*/
176+
customDirectives?: CustomDirectivesConfig;
152177
}
153178

154179
export class BaseDocumentsVisitor<
@@ -180,6 +205,7 @@ export class BaseDocumentsVisitor<
180205
globalNamespace: !!rawConfig.globalNamespace,
181206
operationResultSuffix: getConfigValue(rawConfig.operationResultSuffix, ''),
182207
scalars: buildScalarsFromConfig(_schema, rawConfig, defaultScalars),
208+
customDirectives: getConfigValue(rawConfig.customDirectives, { apolloUnmask: false }),
183209
...((additionalConfig || {}) as any),
184210
});
185211

packages/plugins/other/visitor-plugin-common/src/base-visitor.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,7 @@ export interface RawConfig {
362362
* @description Whether fragment types should be inlined into other operations.
363363
* "inline" is the default behavior and will perform deep inlining fragment types within operation type definitions.
364364
* "combine" is the previous behavior that uses fragment type references without inlining the types (and might cause issues with deeply nested fragment that uses list types).
365+
* "mask" transforms the types for use with fragment masking. Useful when masked types are needed when not using the "client" preset e.g. such as combining it with Apollo Client's data masking feature.
365366
*
366367
* @type string
367368
* @default inline

packages/plugins/other/visitor-plugin-common/src/selection-set-to-object.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -600,7 +600,15 @@ export class SelectionSetToObject<Config extends ParsedDocumentsConfig = ParsedD
600600

601601
if (this._config.inlineFragmentTypes === 'combine' || this._config.inlineFragmentTypes === 'mask') {
602602
fragmentsSpreadUsages.push(selectionNode.typeName);
603-
continue;
603+
604+
const isApolloUnmaskEnabled = this._config.customDirectives.apolloUnmask;
605+
606+
if (
607+
!isApolloUnmaskEnabled ||
608+
(isApolloUnmaskEnabled && !selectionNode.fragmentDirectives?.some(d => d.name.value === 'unmask'))
609+
) {
610+
continue;
611+
}
604612
}
605613

606614
// Handle Fragment Spreads by generating inline types.

packages/plugins/other/visitor-plugin-common/src/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,16 @@ export interface ResolversNonOptionalTypenameConfig {
128128
excludeTypes?: string[];
129129
}
130130

131+
export interface CustomDirectivesConfig {
132+
/**
133+
* @description Adds integration with Apollo Client's `@unmask` directive
134+
* when using Apollo Client's data masking feature. `@unmask` ensures fields
135+
* marked by `@unmask` are available in the type definition.
136+
* @default false
137+
*/
138+
apolloUnmask?: boolean;
139+
}
140+
131141
export interface GenerateInternalResolversIfNeededConfig {
132142
__resolveReference?: boolean;
133143
}

packages/plugins/typescript/operations/tests/ts-documents.spec.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7423,5 +7423,168 @@ function test(q: GetEntityBrandDataQuery): void {
74237423
export type UserFragmentFragment = { __typename?: 'User', id: string } & { ' $fragmentName'?: 'UserFragmentFragment' };
74247424
`);
74257425
});
7426+
7427+
it("'mask' with @unmask configured with apolloUnmask yields correct types", async () => {
7428+
const ast = parse(/* GraphQL */ `
7429+
query {
7430+
me {
7431+
...UserFragment @unmask
7432+
}
7433+
}
7434+
fragment UserFragment on User {
7435+
id
7436+
}
7437+
`);
7438+
const result = await plugin(
7439+
schema,
7440+
[{ location: 'test-file.ts', document: ast }],
7441+
{ inlineFragmentTypes: 'mask', customDirectives: { apolloUnmask: true } },
7442+
{ outputFile: '' }
7443+
);
7444+
expect(result.content).toBeSimilarStringTo(`
7445+
export type Unnamed_1_QueryVariables = Exact<{ [key: string]: never; }>;
7446+
7447+
7448+
export type Unnamed_1_Query = { __typename?: 'Query', me?: (
7449+
{ __typename?: 'User', id: string }
7450+
& { ' $fragmentRefs'?: { 'UserFragmentFragment': UserFragmentFragment } }
7451+
) | null };
7452+
7453+
export type UserFragmentFragment = { __typename?: 'User', id: string } & { ' $fragmentName'?: 'UserFragmentFragment' };
7454+
`);
7455+
});
7456+
7457+
it("'mask' with @unmask without apolloUnmask yields correct types", async () => {
7458+
const ast = parse(/* GraphQL */ `
7459+
query {
7460+
me {
7461+
...UserFragment @unmask
7462+
}
7463+
}
7464+
fragment UserFragment on User {
7465+
id
7466+
}
7467+
`);
7468+
const result = await plugin(
7469+
schema,
7470+
[{ location: 'test-file.ts', document: ast }],
7471+
{ inlineFragmentTypes: 'mask' },
7472+
{ outputFile: '' }
7473+
);
7474+
expect(result.content).toBeSimilarStringTo(`
7475+
export type Unnamed_1_QueryVariables = Exact<{ [key: string]: never; }>;
7476+
7477+
7478+
export type Unnamed_1_Query = { __typename?: 'Query', me?: (
7479+
{ __typename?: 'User' }
7480+
& { ' $fragmentRefs'?: { 'UserFragmentFragment': UserFragmentFragment } }
7481+
) | null };
7482+
7483+
export type UserFragmentFragment = { __typename?: 'User', id: string } & { ' $fragmentName'?: 'UserFragmentFragment' };
7484+
`);
7485+
});
7486+
7487+
it("'mask' with @unmask with apolloUnmask explicitly disabled yields correct types", async () => {
7488+
const ast = parse(/* GraphQL */ `
7489+
query {
7490+
me {
7491+
...UserFragment @unmask
7492+
}
7493+
}
7494+
fragment UserFragment on User {
7495+
id
7496+
}
7497+
`);
7498+
const result = await plugin(
7499+
schema,
7500+
[{ location: 'test-file.ts', document: ast }],
7501+
{ inlineFragmentTypes: 'mask', customDirectives: { apolloUnmask: false } },
7502+
{ outputFile: '' }
7503+
);
7504+
expect(result.content).toBeSimilarStringTo(`
7505+
export type Unnamed_1_QueryVariables = Exact<{ [key: string]: never; }>;
7506+
7507+
7508+
export type Unnamed_1_Query = { __typename?: 'Query', me?: (
7509+
{ __typename?: 'User' }
7510+
& { ' $fragmentRefs'?: { 'UserFragmentFragment': UserFragmentFragment } }
7511+
) | null };
7512+
7513+
export type UserFragmentFragment = { __typename?: 'User', id: string } & { ' $fragmentName'?: 'UserFragmentFragment' };
7514+
`);
7515+
});
7516+
7517+
it("'mask' with @unmask and masked fragments yields correct types", async () => {
7518+
const ast = parse(/* GraphQL */ `
7519+
query {
7520+
me {
7521+
...UserFragment @unmask
7522+
...UserFragment2
7523+
}
7524+
}
7525+
fragment UserFragment on User {
7526+
id
7527+
}
7528+
7529+
fragment UserFragment2 on User {
7530+
email
7531+
}
7532+
`);
7533+
const result = await plugin(
7534+
schema,
7535+
[{ location: 'test-file.ts', document: ast }],
7536+
{ inlineFragmentTypes: 'mask', customDirectives: { apolloUnmask: true } },
7537+
{ outputFile: '' }
7538+
);
7539+
expect(result.content).toBeSimilarStringTo(`
7540+
export type Unnamed_1_QueryVariables = Exact<{ [key: string]: never; }>;
7541+
7542+
7543+
export type Unnamed_1_Query = { __typename?: 'Query', me?: (
7544+
{ __typename?: 'User', id: string }
7545+
& { ' $fragmentRefs'?: { 'UserFragmentFragment': UserFragmentFragment;'UserFragment2Fragment': UserFragment2Fragment } }
7546+
) | null };
7547+
7548+
export type UserFragmentFragment = { __typename?: 'User', id: string } & { ' $fragmentName'?: 'UserFragmentFragment' };
7549+
export type UserFragment2Fragment = { __typename?: 'User', email: string } & { ' $fragmentName'?: 'UserFragment2Fragment' };
7550+
`);
7551+
});
7552+
7553+
it("'mask' with @unmask and masked fragments on overlapping fields yields correct types", async () => {
7554+
const ast = parse(/* GraphQL */ `
7555+
query {
7556+
me {
7557+
...UserFragment @unmask
7558+
...UserFragment2
7559+
}
7560+
}
7561+
fragment UserFragment on User {
7562+
id
7563+
email
7564+
}
7565+
7566+
fragment UserFragment2 on User {
7567+
email
7568+
}
7569+
`);
7570+
const result = await plugin(
7571+
schema,
7572+
[{ location: 'test-file.ts', document: ast }],
7573+
{ inlineFragmentTypes: 'mask', customDirectives: { apolloUnmask: true } },
7574+
{ outputFile: '' }
7575+
);
7576+
expect(result.content).toBeSimilarStringTo(`
7577+
export type Unnamed_1_QueryVariables = Exact<{ [key: string]: never; }>;
7578+
7579+
7580+
export type Unnamed_1_Query = { __typename?: 'Query', me?: (
7581+
{ __typename?: 'User', id: string, email: string }
7582+
& { ' $fragmentRefs'?: { 'UserFragmentFragment': UserFragmentFragment;'UserFragment2Fragment': UserFragment2Fragment } }
7583+
) | null };
7584+
7585+
export type UserFragmentFragment = { __typename?: 'User', id: string, email: string } & { ' $fragmentName'?: 'UserFragmentFragment' };
7586+
export type UserFragment2Fragment = { __typename?: 'User', email: string } & { ' $fragmentName'?: 'UserFragment2Fragment' };
7587+
`);
7588+
});
74267589
});
74277590
});

0 commit comments

Comments
 (0)