Skip to content

Commit

Permalink
feat: implement a graphql-codegen plugin that outputs a client SDK wr…
Browse files Browse the repository at this point in the history
…itten in Effect (dotansimha#664)
  • Loading branch information
vecerek committed May 2, 2024
1 parent c10b8f7 commit 3416b87
Show file tree
Hide file tree
Showing 10 changed files with 3,359 additions and 1,208 deletions.
5 changes: 5 additions & 0 deletions .changeset/eight-ants-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@graphql-codegen/typescript-effect': minor
---

Introduces a graphql-codegen plugin for generating a client SDK using Effect
1 change: 1 addition & 0 deletions packages/plugins/typescript/effect/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('../../../../jest.project')({ dirname: __dirname });
66 changes: 66 additions & 0 deletions packages/plugins/typescript/effect/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
{
"name": "@graphql-codegen/typescript-effect",
"version": "0.0.1",
"type": "module",
"description": "GraphQL Code Generator plugin for generating Effect Platform HTTP Client requests for GraphQL operations",
"repository": {
"type": "git",
"url": "https://github.com/dotansimha/graphql-code-generator-community.git",
"directory": "packages/plugins/typescript/effect"
},
"license": "MIT",
"engines": {
"node": ">= 16.0.0"
},
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
"exports": {
".": {
"require": {
"types": "./dist/typings/index.d.cts",
"default": "./dist/cjs/index.js"
},
"import": {
"types": "./dist/typings/index.d.ts",
"default": "./dist/esm/index.js"
},
"default": {
"types": "./dist/typings/index.d.ts",
"default": "./dist/esm/index.js"
}
},
"./package.json": "./package.json"
},
"typings": "dist/typings/index.d.ts",
"scripts": {
"lint": "eslint **/*.ts",
"test": "jest --no-watchman --config ../../../../jest.config.js"
},
"peerDependencies": {
"@effect/platform": "~0.48.8",
"effect": "^2.4.7",
"graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0",
"graphql-tag": "^2.0.0"
},
"dependencies": {
"@effect/schema": "^0.64.7",
"@graphql-codegen/plugin-helpers": "^3.0.0",
"@graphql-codegen/visitor-plugin-common": "2.13.1",
"auto-bind": "~4.0.0",
"tslib": "~2.6.0"
},
"devDependencies": {
"@effect/platform": "~0.48.8",
"@effect/platform-node": "~0.45.10",
"@graphql-codegen/testing": "1.18.0",
"@graphql-tools/schema": "10.0.3",
"effect": "2.4.7"
},
"publishConfig": {
"directory": "dist",
"access": "public"
},
"typescript": {
"definition": "dist/typings/index.d.ts"
}
}
51 changes: 51 additions & 0 deletions packages/plugins/typescript/effect/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { concatAST, type FragmentDefinitionNode, type GraphQLSchema, Kind } from 'graphql';
import {
oldVisit,
type PluginFunction,
type PluginValidateFn,
type Types,
} from '@graphql-codegen/plugin-helpers';
import {
type LoadedFragment,
type RawClientSideBasePluginConfig,
} from '@graphql-codegen/visitor-plugin-common';
import { EffectVisitor } from './visitor.js';

export const plugin: PluginFunction<{}> = (
schema: GraphQLSchema,
documents: Types.DocumentFile[],
config: RawClientSideBasePluginConfig,
) => {
const allAst = concatAST(documents.map(v => v.document));
const allFragments: LoadedFragment[] = [
...(
allAst.definitions.filter(
d => d.kind === Kind.FRAGMENT_DEFINITION,
) as FragmentDefinitionNode[]
).map(fragmentDef => ({
node: fragmentDef,
name: fragmentDef.name.value,
onType: fragmentDef.typeCondition.name.value,
isExternal: false,
})),
...(config.externalFragments || []),
];
const visitor = new EffectVisitor(schema, allFragments, config);
const visitorResult = oldVisit(allAst, { leave: visitor });

return {
prepend: visitor.getImports(),
content: [
visitor.fragments,
...visitorResult.definitions.filter(t => typeof t === 'string'),
visitor.sdkContent,
].join('\n'),
};
};

export const validate: PluginValidateFn<any> = async (
schema: GraphQLSchema,
documents: Types.DocumentFile[],
config: RawClientSideBasePluginConfig,
outputFile: string,
) => {};
198 changes: 198 additions & 0 deletions packages/plugins/typescript/effect/src/visitor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import autoBind from 'auto-bind';
import { type GraphQLSchema, type OperationDefinitionNode, print } from 'graphql';
import {
type ClientSideBasePluginConfig,
ClientSideBaseVisitor,
DocumentMode,
LoadedFragment,
type RawClientSideBasePluginConfig,
} from '@graphql-codegen/visitor-plugin-common';

export interface EffectPluginConfig extends ClientSideBasePluginConfig {}

const additionalStaticContent = (documentMode: DocumentMode) => `
export type GraphQLSuccessResponse<A> = {
body: ExecutionResult & { data: A };
headers: Record<string, string>;
};
export type GraphQLErrorResponse = {
body: Omit<ExecutionResult, 'data'>;
headers: Record<string, string>;
};
export class MissingDataGraphQLResponseError extends Data.TaggedError(
'MissingDataGraphQLResponseError',
)<GraphQLErrorResponse> {}
export type GraphQLOperationOptions = {
preferredOpName?: string;
};
type GraphQLOperationArgs = {
document: ${documentMode === DocumentMode.string ? 'string' : 'DocumentNode'};
fallbackOperationName: string;
};
// https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#legacy-watershed
const Accept = 'application/graphql-response+json; charset=utf-8, application/json; charset=utf-8';
const makeGraphQLClient = Http.client.mapRequest(
Http.request.setHeaders({ Accept, 'Content-Type': 'application/json' }),
);
const makeGraphQLOperation =
<Vars, Data>({ document, fallbackOperationName }: GraphQLOperationArgs) =>
(variables: Vars, opts?: GraphQLOperationOptions) => {
const operationName = opts?.preferredOpName ?? fallbackOperationName;
const query = ${documentMode === DocumentMode.string ? 'document' : 'print(document)'};
return Effect.flatMap(Http.client.Client, client =>
Http.request.post('').pipe(
Http.request.jsonBody({
query,
operationName,
variables,
}),
Effect.flatMap(Http.client.filterStatusOk(makeGraphQLClient(client))),
Effect.flatMap(
Http.response.schemaJson(
S.struct({
body: S.any,
headers: S.record(S.string, S.string),
}),
),
),
Effect.flatMap(res => {
const body = res.body as ExecutionResult<Data>;
const { headers } = res;
if (body.data) return Effect.succeed(res as GraphQLSuccessResponse<Data>);
return Effect.fail(new MissingDataGraphQLResponseError({ body, headers }));
}),
Effect.scoped,
),
);
};
`;

export class EffectVisitor extends ClientSideBaseVisitor<
RawClientSideBasePluginConfig,
EffectPluginConfig
> {
private _externalImportPrefix: string;
private _operationsToInclude: {
node: OperationDefinitionNode;
documentVariableName: string;
operationType: string;
operationResultType: string;
operationVariablesTypes: string;
}[] = [];

constructor(
schema: GraphQLSchema,
fragments: LoadedFragment[],
rawConfig: RawClientSideBasePluginConfig,
) {
super(schema, fragments, rawConfig, {});

autoBind(this);

type ImportType = 'type' | 'value';
type Import = [name: string, type: ImportType];
const createNamedImport = (imports: Import[], from: string) => {
const normalizedImports = imports.map(([name, type]) =>
this.config.useTypeImports && type === 'type' ? `type ${name}` : name,
);

return `import { ${normalizedImports.join(', ')} } from '${from}';`;
};
const createNamesaceImport = (namespace: string, from: string, type: ImportType = 'value') =>
`import ${type === 'type' ? 'type ' : ''}* as ${namespace} from '${from}';`;

[
createNamedImport(
[
['Data', 'value'],
['Effect', 'value'],
],
'effect',
),
createNamedImport(
[
['DocumentNode', 'type'],
['ExecutionResult', 'type'],
['print', 'value'],
],
'graphql',
),
createNamesaceImport('Http', '@effect/platform/HttpClient'),
createNamesaceImport('S', '@effect/schema/Schema'),
].forEach(_ => this._additionalImports.push(_));

this._externalImportPrefix = this.config.importOperationTypesFrom
? `${this.config.importOperationTypesFrom}.`
: '';
}

public OperationDefinition(node: OperationDefinitionNode) {
const operationName = node.name?.value;

if (!operationName) {
// eslint-disable-next-line no-console
console.warn(
`Anonymous GraphQL operation was ignored in "typescript-effect", please make sure to name your operation: `,
print(node),
);

return null;
}

return super.OperationDefinition(node);
}

protected buildOperation(
node: OperationDefinitionNode,
documentVariableName: string,
operationType: string,
operationResultType: string,
operationVariablesTypes: string,
): string {
operationResultType = this._externalImportPrefix + operationResultType;
operationVariablesTypes = this._externalImportPrefix + operationVariablesTypes;

this._operationsToInclude.push({
node,
documentVariableName,
operationType,
operationResultType,
operationVariablesTypes,
});

return null;
}

private getDocumentNodeVariable(documentVariableName: string): string {
return this.config.documentMode === DocumentMode.external
? `Operations.${documentVariableName}`
: documentVariableName;
}

public get sdkContent(): string {
const allPossibleOperations = this._operationsToInclude.map(
({ node, documentVariableName, operationResultType, operationVariablesTypes }) => {
const operationName = node.name.value;
const docVarName = this.getDocumentNodeVariable(documentVariableName);
return `export const ${operationName} = makeGraphQLOperation<${operationVariablesTypes}, ${operationResultType}>({
document: ${docVarName},
fallbackOperationName: '${operationName}',
});`;
},
);

return `${additionalStaticContent(this.config.documentMode)}\n${allPossibleOperations.join(
'\n',
)}\n`;
}
}
Loading

0 comments on commit 3416b87

Please sign in to comment.