forked from dotansimha/graphql-code-generator-community
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: implement a graphql-codegen plugin that outputs a client SDK wr…
…itten in Effect (dotansimha#664)
- Loading branch information
Showing
9 changed files
with
3,259 additions
and
1,212 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
module.exports = require('../../../../jest.project')({ dirname: __dirname }); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,66 @@ | ||
{ | ||
"name": "@graphql-codegen/typescript-effect", | ||
"version": "0.1.0", | ||
"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" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
) => {}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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`; | ||
} | ||
} |
Oops, something went wrong.