Skip to content

Commit

Permalink
Keep fields specified by @unmask for use with Apollo Client data ma…
Browse files Browse the repository at this point in the history
…sking (#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
  • Loading branch information
jerelmiller authored Nov 13, 2024
1 parent 1617e3c commit fa64fbf
Show file tree
Hide file tree
Showing 6 changed files with 216 additions and 2 deletions.
6 changes: 6 additions & 0 deletions .changeset/twelve-windows-give.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@graphql-codegen/visitor-plugin-common': minor
'@graphql-codegen/typescript-operations': minor
---

Add support for Apollo Client `@unmask` directive with fragment masking.
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { ParsedTypesConfig, RawTypesConfig } from './base-types-visitor.js';
import { BaseVisitor } from './base-visitor.js';
import { DEFAULT_SCALARS } from './scalars.js';
import { SelectionSetToObject } from './selection-set-to-object.js';
import { NormalizedScalarsMap } from './types.js';
import { NormalizedScalarsMap, CustomDirectivesConfig } from './types.js';
import { buildScalarsFromConfig, DeclarationBlock, DeclarationBlockConfig, getConfigValue } from './utils.js';
import { OperationVariablesToObject } from './variables-to-object.js';

Expand Down Expand Up @@ -40,6 +40,7 @@ export interface ParsedDocumentsConfig extends ParsedTypesConfig {
skipTypeNameForRoot: boolean;
experimentalFragmentVariables: boolean;
mergeFragmentTypes: boolean;
customDirectives: CustomDirectivesConfig;
}

export interface RawDocumentsConfig extends RawTypesConfig {
Expand Down Expand Up @@ -149,6 +150,30 @@ export interface RawDocumentsConfig extends RawTypesConfig {
* @ignore
*/
namespacedImportName?: string;

/**
* @description Configures behavior for use with custom directives from
* various GraphQL libraries.
* @exampleMarkdown
* ```ts filename="codegen.ts"
* import type { CodegenConfig } from '@graphql-codegen/cli';
*
* const config: CodegenConfig = {
* // ...
* generates: {
* 'path/to/file.ts': {
* plugins: ['typescript'],
* config: {
* customDirectives: {
* apolloUnmask: true
* }
* },
* },
* },
* };
* export default config;
*/
customDirectives?: CustomDirectivesConfig;
}

export class BaseDocumentsVisitor<
Expand Down Expand Up @@ -180,6 +205,7 @@ export class BaseDocumentsVisitor<
globalNamespace: !!rawConfig.globalNamespace,
operationResultSuffix: getConfigValue(rawConfig.operationResultSuffix, ''),
scalars: buildScalarsFromConfig(_schema, rawConfig, defaultScalars),
customDirectives: getConfigValue(rawConfig.customDirectives, { apolloUnmask: false }),
...((additionalConfig || {}) as any),
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,7 @@ export interface RawConfig {
* @description Whether fragment types should be inlined into other operations.
* "inline" is the default behavior and will perform deep inlining fragment types within operation type definitions.
* "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).
* "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.
*
* @type string
* @default inline
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -600,7 +600,15 @@ export class SelectionSetToObject<Config extends ParsedDocumentsConfig = ParsedD

if (this._config.inlineFragmentTypes === 'combine' || this._config.inlineFragmentTypes === 'mask') {
fragmentsSpreadUsages.push(selectionNode.typeName);
continue;

const isApolloUnmaskEnabled = this._config.customDirectives.apolloUnmask;

if (
!isApolloUnmaskEnabled ||
(isApolloUnmaskEnabled && !selectionNode.fragmentDirectives?.some(d => d.name.value === 'unmask'))
) {
continue;
}
}

// Handle Fragment Spreads by generating inline types.
Expand Down
10 changes: 10 additions & 0 deletions packages/plugins/other/visitor-plugin-common/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,16 @@ export interface ResolversNonOptionalTypenameConfig {
excludeTypes?: string[];
}

export interface CustomDirectivesConfig {
/**
* @description Adds integration with Apollo Client's `@unmask` directive
* when using Apollo Client's data masking feature. `@unmask` ensures fields
* marked by `@unmask` are available in the type definition.
* @default false
*/
apolloUnmask?: boolean;
}

export interface GenerateInternalResolversIfNeededConfig {
__resolveReference?: boolean;
}
Expand Down
163 changes: 163 additions & 0 deletions packages/plugins/typescript/operations/tests/ts-documents.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7423,5 +7423,168 @@ function test(q: GetEntityBrandDataQuery): void {
export type UserFragmentFragment = { __typename?: 'User', id: string } & { ' $fragmentName'?: 'UserFragmentFragment' };
`);
});

it("'mask' with @unmask configured with apolloUnmask yields correct types", async () => {
const ast = parse(/* GraphQL */ `
query {
me {
...UserFragment @unmask
}
}
fragment UserFragment on User {
id
}
`);
const result = await plugin(
schema,
[{ location: 'test-file.ts', document: ast }],
{ inlineFragmentTypes: 'mask', customDirectives: { apolloUnmask: true } },
{ outputFile: '' }
);
expect(result.content).toBeSimilarStringTo(`
export type Unnamed_1_QueryVariables = Exact<{ [key: string]: never; }>;
export type Unnamed_1_Query = { __typename?: 'Query', me?: (
{ __typename?: 'User', id: string }
& { ' $fragmentRefs'?: { 'UserFragmentFragment': UserFragmentFragment } }
) | null };
export type UserFragmentFragment = { __typename?: 'User', id: string } & { ' $fragmentName'?: 'UserFragmentFragment' };
`);
});

it("'mask' with @unmask without apolloUnmask yields correct types", async () => {
const ast = parse(/* GraphQL */ `
query {
me {
...UserFragment @unmask
}
}
fragment UserFragment on User {
id
}
`);
const result = await plugin(
schema,
[{ location: 'test-file.ts', document: ast }],
{ inlineFragmentTypes: 'mask' },
{ outputFile: '' }
);
expect(result.content).toBeSimilarStringTo(`
export type Unnamed_1_QueryVariables = Exact<{ [key: string]: never; }>;
export type Unnamed_1_Query = { __typename?: 'Query', me?: (
{ __typename?: 'User' }
& { ' $fragmentRefs'?: { 'UserFragmentFragment': UserFragmentFragment } }
) | null };
export type UserFragmentFragment = { __typename?: 'User', id: string } & { ' $fragmentName'?: 'UserFragmentFragment' };
`);
});

it("'mask' with @unmask with apolloUnmask explicitly disabled yields correct types", async () => {
const ast = parse(/* GraphQL */ `
query {
me {
...UserFragment @unmask
}
}
fragment UserFragment on User {
id
}
`);
const result = await plugin(
schema,
[{ location: 'test-file.ts', document: ast }],
{ inlineFragmentTypes: 'mask', customDirectives: { apolloUnmask: false } },
{ outputFile: '' }
);
expect(result.content).toBeSimilarStringTo(`
export type Unnamed_1_QueryVariables = Exact<{ [key: string]: never; }>;
export type Unnamed_1_Query = { __typename?: 'Query', me?: (
{ __typename?: 'User' }
& { ' $fragmentRefs'?: { 'UserFragmentFragment': UserFragmentFragment } }
) | null };
export type UserFragmentFragment = { __typename?: 'User', id: string } & { ' $fragmentName'?: 'UserFragmentFragment' };
`);
});

it("'mask' with @unmask and masked fragments yields correct types", async () => {
const ast = parse(/* GraphQL */ `
query {
me {
...UserFragment @unmask
...UserFragment2
}
}
fragment UserFragment on User {
id
}
fragment UserFragment2 on User {
email
}
`);
const result = await plugin(
schema,
[{ location: 'test-file.ts', document: ast }],
{ inlineFragmentTypes: 'mask', customDirectives: { apolloUnmask: true } },
{ outputFile: '' }
);
expect(result.content).toBeSimilarStringTo(`
export type Unnamed_1_QueryVariables = Exact<{ [key: string]: never; }>;
export type Unnamed_1_Query = { __typename?: 'Query', me?: (
{ __typename?: 'User', id: string }
& { ' $fragmentRefs'?: { 'UserFragmentFragment': UserFragmentFragment;'UserFragment2Fragment': UserFragment2Fragment } }
) | null };
export type UserFragmentFragment = { __typename?: 'User', id: string } & { ' $fragmentName'?: 'UserFragmentFragment' };
export type UserFragment2Fragment = { __typename?: 'User', email: string } & { ' $fragmentName'?: 'UserFragment2Fragment' };
`);
});

it("'mask' with @unmask and masked fragments on overlapping fields yields correct types", async () => {
const ast = parse(/* GraphQL */ `
query {
me {
...UserFragment @unmask
...UserFragment2
}
}
fragment UserFragment on User {
id
email
}
fragment UserFragment2 on User {
email
}
`);
const result = await plugin(
schema,
[{ location: 'test-file.ts', document: ast }],
{ inlineFragmentTypes: 'mask', customDirectives: { apolloUnmask: true } },
{ outputFile: '' }
);
expect(result.content).toBeSimilarStringTo(`
export type Unnamed_1_QueryVariables = Exact<{ [key: string]: never; }>;
export type Unnamed_1_Query = { __typename?: 'Query', me?: (
{ __typename?: 'User', id: string, email: string }
& { ' $fragmentRefs'?: { 'UserFragmentFragment': UserFragmentFragment;'UserFragment2Fragment': UserFragment2Fragment } }
) | null };
export type UserFragmentFragment = { __typename?: 'User', id: string, email: string } & { ' $fragmentName'?: 'UserFragmentFragment' };
export type UserFragment2Fragment = { __typename?: 'User', email: string } & { ' $fragmentName'?: 'UserFragment2Fragment' };
`);
});
});
});

0 comments on commit fa64fbf

Please sign in to comment.