Skip to content

Instantly share code, notes, and snippets.

@osdiab
Last active November 23, 2024 07:15
Show Gist options
  • Save osdiab/054c4e1c9c7404edd87a38728622df3f to your computer and use it in GitHub Desktop.
Save osdiab/054c4e1c9c7404edd87a38728622df3f to your computer and use it in GitHub Desktop.
fragment masking with graphql-codegen near-operation-file preset
import type { PluginFunction } from "@graphql-codegen/plugin-helpers";
export type FragmentMaskingPluginConfig = {
useTypeImports?: boolean;
augmentedModuleName?: string;
unmaskFunctionName?: string;
emitLegacyCommonJSImports?: boolean;
isStringDocumentMode?: boolean;
typesImportPath?: string; // this is new
};
const fragmentTypeHelper = `
export type FragmentType<TDocumentType extends DocumentTypeDecoration<any, any>> = TDocumentType extends DocumentTypeDecoration<
infer TType,
any
>
? [TType] extends [{ ' $fragmentName'?: infer TKey }]
? TKey extends string
? { ' $fragmentRefs'?: { [key in TKey]: TType } }
: never
: never
: never;`;
const makeFragmentDataHelper = `
export function makeFragmentData<
F extends DocumentTypeDecoration<any, any>,
FT extends ResultOf<F>
>(data: FT, _fragment: F): FragmentType<F> {
return data as FragmentType<F>;
}`;
const defaultUnmaskFunctionName = "useFragment";
const createUnmaskFunctionTypeDefinitions = (
unmaskFunctionName = defaultUnmaskFunctionName,
) => [
`// return non-nullable if \`fragmentType\` is non-nullable
export function ${unmaskFunctionName}<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>>
): TType;`,
`// return nullable if \`fragmentType\` is undefined
export function ${unmaskFunctionName}<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>> | undefined
): TType | undefined;`,
`// return nullable if \`fragmentType\` is nullable
export function ${unmaskFunctionName}<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>> | null
): TType | null;`,
`// return nullable if \`fragmentType\` is nullable or undefined
export function ${unmaskFunctionName}<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>> | null | undefined
): TType | null | undefined;`,
`// return array of non-nullable if \`fragmentType\` is array of non-nullable
export function ${unmaskFunctionName}<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: Array<FragmentType<DocumentTypeDecoration<TType, any>>>
): Array<TType>;`,
`// return array of nullable if \`fragmentType\` is array of nullable
export function ${unmaskFunctionName}<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: Array<FragmentType<DocumentTypeDecoration<TType, any>>> | null | undefined
): Array<TType> | null | undefined;`,
`// return readonly array of non-nullable if \`fragmentType\` is array of non-nullable
export function ${unmaskFunctionName}<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: ReadonlyArray<FragmentType<DocumentTypeDecoration<TType, any>>>
): ReadonlyArray<TType>;`,
`// return readonly array of nullable if \`fragmentType\` is array of nullable
export function ${unmaskFunctionName}<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: ReadonlyArray<FragmentType<DocumentTypeDecoration<TType, any>>> | null | undefined
): ReadonlyArray<TType> | null | undefined;`,
];
const createUnmaskFunction = (
unmaskFunctionName = defaultUnmaskFunctionName,
) => `
${createUnmaskFunctionTypeDefinitions(unmaskFunctionName).join("\n")}
export function ${unmaskFunctionName}<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>> | Array<FragmentType<DocumentTypeDecoration<TType, any>>> | ReadonlyArray<FragmentType<DocumentTypeDecoration<TType, any>>> | null | undefined
): TType | Array<TType> | ReadonlyArray<TType> | null | undefined {
return fragmentType as any;
}
`;
const isFragmentReadyFunction = (isStringDocumentMode: boolean) => {
if (isStringDocumentMode) {
return `\
export function isFragmentReady<TQuery, TFrag>(
queryNode: TypedDocumentString<TQuery, any>,
fragmentNode: TypedDocumentString<TFrag, any>,
data: FragmentType<TypedDocumentString<Incremental<TFrag>, any>> | null | undefined
): data is FragmentType<typeof fragmentNode> {
const deferredFields = queryNode.__meta__?.deferredFields as Record<string, (keyof TFrag)[]>;
const fragName = fragmentNode.__meta__?.fragmentName as string | undefined;
if (!deferredFields || !fragName) return true;
const fields = deferredFields[fragName] ?? [];
return fields.length > 0 && fields.every(field => data && field in data);
}
`;
}
return `\
export function isFragmentReady<TQuery, TFrag>(
queryNode: DocumentTypeDecoration<TQuery, any>,
fragmentNode: TypedDocumentNode<TFrag>,
data: FragmentType<TypedDocumentNode<Incremental<TFrag>, any>> | null | undefined
): data is FragmentType<typeof fragmentNode> {
const deferredFields = (queryNode as { __meta__?: { deferredFields: Record<string, (keyof TFrag)[]> } }).__meta__
?.deferredFields;
if (!deferredFields) return true;
const fragDef = fragmentNode.definitions[0] as FragmentDefinitionNode | undefined;
const fragName = fragDef?.name?.value;
const fields = (fragName && deferredFields[fragName]) || [];
return fields.length > 0 && fields.every(field => data && field in data);
}
`;
};
/**
* Plugin for generating fragment masking helper functions.
*/
export const plugin: PluginFunction<FragmentMaskingPluginConfig> = (
_1,
_2,
{
useTypeImports,
augmentedModuleName,
unmaskFunctionName,
emitLegacyCommonJSImports,
isStringDocumentMode = false,
typesImportPath,
},
_info,
) => {
const documentNodeImport = `${
useTypeImports ? "import type" : "import"
} { ResultOf, DocumentTypeDecoration${
isStringDocumentMode ? "" : ", TypedDocumentNode"
} } from '@graphql-typed-document-node/core';\n`;
const deferFragmentHelperImports = `${
useTypeImports ? "import type" : "import"
} { Incremental${
isStringDocumentMode ? ", TypedDocumentString" : ""
} } from '${
typesImportPath ?? `./graphql${emitLegacyCommonJSImports ? "" : ".js"}`
}';\n`;
const fragmentDefinitionNodeImport = isStringDocumentMode
? ""
: `${
useTypeImports ? "import type" : "import"
} { FragmentDefinitionNode } from 'graphql';\n`;
if (augmentedModuleName == null) {
return [
documentNodeImport,
fragmentDefinitionNodeImport,
deferFragmentHelperImports,
`\n`,
fragmentTypeHelper,
`\n`,
createUnmaskFunction(unmaskFunctionName),
`\n`,
makeFragmentDataHelper,
`\n`,
isFragmentReadyFunction(isStringDocumentMode),
].join(``);
}
return [
documentNodeImport,
`declare module "${augmentedModuleName}" {`,
[
...fragmentTypeHelper.split(`\n`),
`\n`,
...createUnmaskFunctionTypeDefinitions(unmaskFunctionName)
.join("\n")
.split("\n"),
`\n`,
makeFragmentDataHelper,
]
.map((line) => (line === `\n` || line === "" ? line : ` ${line}`))
.join(`\n`),
`}`,
].join(`\n`);
};
// this is a graphql codegen config specifically for operations, meaning queries and mutations documents
import type { CodegenConfig } from "@graphql-codegen/cli";
import type { TypeScriptDocumentsPluginConfig } from "@graphql-codegen/typescript-operations";
import type { TypeScriptTypedDocumentNodesConfig } from "@graphql-codegen/typed-document-node";
const typescriptOperationsConfig: TypeScriptDocumentsPluginConfig = {
inlineFragmentTypes: "mask", // this undocumented flag makes the generated operations produce masked fragments
// whatever else you need in here, e.g. changing scalar types and what not
};
const typedDocumentNodeConfig: TypeScriptTypedDocumentNodesConfig = {};
const config: CodegenConfig = {
overwrite: true,
schema: [
// just get the schema from the generated file from the schema config;
// if you configure it some other way, change this to wherever your schema actually is
"./gen/gql-schema/schema.graphql",
],
// it's useful to only watch for your gql documents so that you can just regenerate the giant
// types file when your schema actually changes, and only regenerate operations files when
// the queries and mutations actually change
documents: ["./src/**/*.graphql"],
generates: {
"gen/graphql/": {
preset: "near-operation-file",
plugins: [
{ "typescript-operations": typescriptOperationsConfig },
{ "typed-document-node": typedDocumentNodeConfig },
// add whatever plugins you need here; e.g. we use the add plugin
// to import some custom types that give us more specific scalars
],
presetConfig: {
// The double ~ is because in my codebase we export the generated graphql types to a
// gen/graphql folder at our project root and alias the `gen/` folder to a `~gen/`
// path in our tsconfig.json, but for some reason graphql-codegen uses the ~ character
// to signify the root of the project, so we end up with this silly looking double ~.
// For your own project you'll want to modify this to match wherever you generate your types
baseTypesPath: "~~gen/graphql/types",
extension: ".gen-graphql.ts", // this is optional, just the convention we use
},
},
},
};
export default config;
{
"scripts": [
// we use pnpm, change these to match whatever you use
"gen:gql:schema": "graphql-codegen --config schema-config.ts",
"gen:gql:operations": "graphql-codegen --config operations-config.ts",
"gen:gql": "pnpm gen:gql:schema && pnpm gen:gql:operations",
"dev:gql": "pnpm gen:gql:operations --watch"
// In our codebase we also have a dev:gql:schema command, because we use Hasura, and whenever the
// graphql schema it generates changes, it also updates some files locally; we use chokidar-cli
// to retrigger the gen:gql:schema command whenever that file changes.
// That's specific to our setup so i've removed that from this gist, but that can serve as inspiration
// for however you want to watch for changes to your own remote schema if you use that type of setup
// (e.g. polling or something else)
],
"devDependencies": {
"@graphql-codegen/cli": "^5.0.3",
"@graphql-codegen/introspection": "3.0.0",
"@graphql-codegen/near-operation-file-preset": "^3.0.0",
"@graphql-codegen/plugin-helpers": "^5.1.0",
"@graphql-codegen/schema-ast": "^4.1.0",
"@graphql-codegen/typed-document-node": "^5.0.11",
"@graphql-codegen/typescript": "^4.1.1",
"@graphql-codegen/typescript-operations": "^4.3.1",
"@graphql-typed-document-node/core": "^3.2.0"
}
}
import type { CodegenConfig } from "@graphql-codegen/cli";
import type { FragmentMaskingPluginConfig } from "./fragment-masking-plugin";
import "./fragment-masking-plugin"; // need this or else plugin fails to be loaded by codegen CLI
export const typescriptPluginConfig: TypeScriptPluginConfig = {
inlineFragmentTypes: "mask",
// whatever else you need in here, e.g. changing scalar types and what not
};
const fragmentMaskingPluginConfig: FragmentMaskingPluginConfig = {
// i added this config because in my project we now control where the types are
// located relative to the rest of the generated graphql, so we need control
typesImportPath: "~gen/graphql/types",
};
const config: CodegenConfig = {
overwrite: true,
schema: [
// TODO: add wherever you get your actual graphql schema, for us it's a remote source
// in addition to some schema overrides
],
ignoreNoDocuments: true,
generates: {
// if your schema is produced some other way like manually in your codebase, you won't
// need this one
"gen/gql-schema/schema.graphql": {
plugins: ["schema-ast"],
},
"gen/graphql/types.ts": {
plugins: [
{ typescript: typescriptPluginConfig },
// add whatever plugins you need here; e.g. we use the add plugin
// to import some custom types that give us more specific scalars
],
},
"gen/graphql/fragment-masking.ts": {
plugins: [
{
"gql-codegen/fragment-masking-plugin.ts": fragmentMaskingPluginConfig,
},
],
},
},
};
export default config;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment