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

Keep fields specified by @unmask for use with Apollo Client data masking #10163

Merged
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
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
16 changes: 16 additions & 0 deletions .changeset/twelve-windows-give.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@graphql-codegen/typed-document-node': minor
eddeee888 marked this conversation as resolved.
Show resolved Hide resolved
'@graphql-codegen/gql-tag-operations': minor
'@graphql-codegen/visitor-plugin-common': minor
'@graphql-codegen/typescript-document-nodes': minor
'@graphql-codegen/typescript-operations': minor
'@graphql-codegen/typescript': minor
'@graphql-codegen/testing': minor
'@graphql-codegen/typescript-resolvers': minor
'@graphql-codegen/graphql-modules-preset': minor
'@graphql-codegen/plugin-helpers': minor
'@graphql-codegen/cli': minor
'@graphql-codegen/client-preset': 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;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is the new config option to enable the new apolloUnmask directive. Let me know if there is a better place for these.

}

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.
eddeee888 marked this conversation as resolved.
Show resolved Hide resolved
*
* @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
12 changes: 11 additions & 1 deletion packages/plugins/other/visitor-plugin-common/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,17 @@ 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;
}
export type NormalizedGenerateInternalResolversIfNeededConfig = Required<GenerateInternalResolversIfNeededConfig>;
export type NormalizedGenerateInternalResolversIfNeededConfig = Required<GenerateInternalResolversIfNeededConfig>;
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
eddeee888 marked this conversation as resolved.
Show resolved Hide resolved
}
}
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
eddeee888 marked this conversation as resolved.
Show resolved Hide resolved
...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' };
`);
});
});
});
Loading