feat(graphql): generate types from graphql files (#2014)

Co-authored-by: forehalo <forehalo@gmail.com>
Co-authored-by: Himself65 <himself65@outlook.com>
This commit is contained in:
LongYinan
2023-04-25 10:13:52 +08:00
committed by GitHub
parent 4528df07a5
commit 5c673a8ffc
21 changed files with 2445 additions and 65 deletions

View File

@@ -12,6 +12,7 @@
"component",
"workspace",
"env",
"graphql",
"cli",
"hooks",
"i18n",

View File

@@ -75,6 +75,12 @@ const config = {
'@typescript-eslint/consistent-type-imports': 0,
},
},
{
files: '*.cjs',
rules: {
'@typescript-eslint/no-var-requires': 0,
},
},
],
};

View File

@@ -54,6 +54,7 @@
"**/dist/**"
],
"env": {
"TS_NODE_TRANSPILE_ONLY": true,
"TS_NODE_PROJECT": "./tsconfig.json",
"NODE_ENV": "development",
"DEBUG": "affine:*",

View File

@@ -66,4 +66,4 @@ export class ConfigModule {
};
}
export { AFFiNEConfig } from './def';
export type { AFFiNEConfig } from './def';

View File

@@ -14,6 +14,7 @@
"@affine/component": "workspace:*",
"@affine/debug": "workspace:*",
"@affine/env": "workspace:*",
"@affine/graphql": "workspace:*",
"@affine/i18n": "workspace:*",
"@affine/jotai": "workspace:*",
"@affine/templates": "workspace:*",
@@ -36,6 +37,7 @@
"cmdk": "^0.2.0",
"css-spring": "^4.1.0",
"dayjs": "^1.11.7",
"graphql": "^16.6.0",
"jotai": "^2.0.4",
"jotai-devtools": "^0.4.0",
"lit": "^2.7.2",

View File

@@ -0,0 +1,139 @@
/**
* @vitest-environment happy-dom
*/
import { render } from '@testing-library/react';
import type { Mock } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useMutation, useQuery } from '../gql';
let fetch: Mock;
describe('GraphQL wrapper for SWR', () => {
beforeEach(() => {
fetch = vi.fn(() =>
Promise.resolve(
new Response(JSON.stringify({ data: { hello: 1 } }), {
headers: {
'content-type': 'application/json',
},
})
)
);
vi.stubGlobal('fetch', fetch);
});
afterEach(() => {
fetch.mockReset();
});
describe('useQuery', () => {
const Component = ({ id }: { id: number }) => {
const { data, isLoading, error } = useQuery({
query: {
id: 'query',
query: `
query {
hello
}
`,
operationName: 'query',
definitionName: 'query',
},
// @ts-expect-error forgive the fake variables
variables: { id },
});
if (isLoading) {
return <div>loading</div>;
}
if (error) {
return <div>error</div>;
}
// @ts-expect-error
return <div>number: {data!.hello}</div>;
};
it('should send query correctly', async () => {
const component = <Component id={1} />;
const renderer = render(component);
const el = await renderer.findByText('number: 1');
expect(el).toMatchInlineSnapshot(`
<div>
number:
1
</div>
`);
});
it('should not send request if cache hit', async () => {
const component = <Component id={2} />;
const renderer = render(component);
expect(fetch).toBeCalledTimes(1);
renderer.rerender(component);
expect(fetch).toBeCalledTimes(1);
render(<Component id={3} />);
expect(fetch).toBeCalledTimes(2);
});
});
describe('useMutation', () => {
const Component = () => {
const { trigger, error, isMutating } = useMutation({
mutation: {
id: 'mutation',
query: `
mutation {
hello
}
`,
operationName: 'mutation',
definitionName: 'mutation',
},
});
if (isMutating) {
return <div>mutating</div>;
}
if (error) {
return <div>error</div>;
}
return (
<div>
<button
onClick={() =>
// @ts-expect-error forgive the fake variables
trigger()
}
>
click
</button>
</div>
);
};
it('should trigger mutation', async () => {
const component = <Component />;
const renderer = render(component);
const button = await renderer.findByText('click');
button.click();
expect(fetch).toBeCalledTimes(1);
renderer.rerender(component);
expect(renderer.asFragment()).toMatchInlineSnapshot(`
<DocumentFragment>
<div>
mutating
</div>
</DocumentFragment>
`);
});
});
});

109
apps/web/src/shared/gql.ts Normal file
View File

@@ -0,0 +1,109 @@
import { prefixUrl } from '@affine/env';
import type {
GraphQLQuery,
MutationOptions,
QueryOptions,
QueryResponse,
QueryVariables,
} from '@affine/graphql';
import { gqlFetcherFactory } from '@affine/graphql';
import type { GraphQLError } from 'graphql';
import type { SWRConfiguration, SWRResponse } from 'swr';
import useSWR from 'swr';
import type {
SWRMutationConfiguration,
SWRMutationResponse,
} from 'swr/mutation';
import useSWRMutation from 'swr/mutation';
const fetcher = gqlFetcherFactory(prefixUrl + '/graphql');
/**
* A `useSWR` wrapper for sending graphql queries
*
* @example
*
* ```ts
* import { someQuery, someQueryWithNoVars } from '@affine/graphql'
*
* const swrResponse1 = useQuery({
* query: workspaceByIdQuery,
* variables: { id: '1' }
* })
*
* const swrResponse2 = useQuery({
* query: someQueryWithNoVars
* })
* ```
*/
export function useQuery<Query extends GraphQLQuery>(
options: QueryOptions<Query>
): SWRResponse<QueryResponse<Query>, GraphQLError | GraphQLError[]>;
export function useQuery<Query extends GraphQLQuery>(
options: QueryOptions<Query>,
config: Omit<
SWRConfiguration<
QueryResponse<Query>,
GraphQLError | GraphQLError[],
typeof fetcher<Query>
>,
'fetcher'
>
): SWRResponse<QueryResponse<Query>, GraphQLError | GraphQLError[]>;
export function useQuery(options: QueryOptions<GraphQLQuery>, config?: any) {
return useSWR(
() => [options.query.id, options.variables],
() => fetcher(options),
config
);
}
/**
* A useSWRMutation wrapper for sending graphql mutations
*
* @example
*
* ```ts
* import { someMutation } from '@affine/graphql'
*
* const { trigger } = useMutation({
* mutation: someMutation,
* })
*
* trigger({ name: 'John Doe' })
*/
export function useMutation<Mutation extends GraphQLQuery>(
options: Omit<MutationOptions<GraphQLQuery>, 'variables'>
): SWRMutationResponse<
QueryResponse<Mutation>,
GraphQLError | GraphQLError[],
QueryVariables<Mutation>
>;
export function useMutation<Mutation extends GraphQLQuery>(
options: Omit<MutationOptions<GraphQLQuery>, 'variables'>,
config: Omit<
SWRMutationConfiguration<
QueryResponse<Mutation>,
GraphQLError | GraphQLError[],
QueryVariables<Mutation>
>,
'fetcher'
>
): SWRMutationResponse<
QueryResponse<Mutation>,
GraphQLError | GraphQLError[],
QueryVariables<Mutation>
>;
export function useMutation(
options: Omit<MutationOptions<GraphQLQuery>, 'variables'>,
config?: any
) {
return useSWRMutation(
options.mutation.id,
(_: string, { arg }: { arg: QueryVariables<any> }) =>
fetcher({ ...options, query: options.mutation, variables: arg }),
config
);
}
export const gql = fetcher;

View File

@@ -0,0 +1,3 @@
# `GraphQL` client
Auto generated `GraphQL` client for affine.pro

View File

@@ -0,0 +1,31 @@
hooks:
afterOneFileWrite:
- prettier --write
config:
strict: true
maybeValue: T | null
declarationKind: interface
avoidOptionals: true
preResolveTypes: true
onlyOperationTypes: true
namingConvention:
enumValues: keep
scalars:
DateTime: string
Date: string
Decimal: number
UUID: string
ID: string
JSON: any
overwrite: true
schema: ../../apps/server/src/schema.gql
documents: ./src/**/*.gql
generates:
./src/schema.ts:
plugins:
- typescript
- typescript-operations
- add:
content: '/* eslint-disable */'
- ./export-gql-plugin.cjs:
output: ./src/graphql/index.ts

View File

@@ -0,0 +1,207 @@
const fs = require('fs');
const path = require('path');
const { Kind, print } = require('graphql');
const { upperFirst, lowerFirst } = require('lodash');
/**
* return exported name used in runtime.
*
* @param {import('graphql').ExecutableDefinitionNode} def
* @returns {string}
*/
function getExportedName(def) {
const name = lowerFirst(def.name?.value);
const suffix =
def.kind === Kind.OPERATION_DEFINITION
? upperFirst(def.operation)
: 'Fragment';
return name.endsWith(suffix) ? name : name + suffix;
}
/**
* @type {import('@graphql-codegen/plugin-helpers').CodegenPlugin}
*/
module.exports = {
plugin: (_schema, documents, { output }) => {
const nameLocationMap = new Map();
const locationSourceMap = new Map(
documents
.filter(source => !!source.location)
.map(source => [source.location, source])
);
/**
* @type {string[]}
*/
const defs = [];
const queries = [];
const mutations = [];
for (const [location, source] of locationSourceMap) {
if (
!source ||
!source.document ||
!location ||
source.document.kind !== Kind.DOCUMENT ||
!source.document.definitions ||
!source.document.definitions.length
) {
return;
}
const doc = source.document;
if (doc.definitions.length > 1) {
throw new Error('Only support one definition per file.');
}
const definition = doc.definitions[0];
if (!definition) {
throw new Error(`Found empty file ${location}.`);
}
if (
!definition.selectionSet ||
!definition.selectionSet.selections ||
definition.selectionSet.selections.length === 0
) {
throw new Error(`Found empty fields selection in file ${location}`);
}
if (
definition.kind === Kind.OPERATION_DEFINITION ||
definition.kind === Kind.FRAGMENT_DEFINITION
) {
if (!definition.name) {
throw new Error(`Anonymous definition found in ${location}`);
}
const exportedName = getExportedName(definition);
// duplication checking
if (nameLocationMap.has(exportedName)) {
throw new Error(
`name ${exportedName} export from ${location} are duplicated.`
);
} else {
/**
* @type {import('graphql').DefinitionNode[]}
*/
let importedDefinitions = [];
if (source.location) {
fs.readFileSync(source.location, 'utf8')
.split(/\r\n|\r|\n/)
.forEach(line => {
if (line[0] === '#') {
const [importKeyword, importPath] = line
.split(' ')
.filter(Boolean);
if (importKeyword === '#import') {
const realImportPath = path.posix.join(
location,
'..',
importPath.replace(/["']/g, '')
);
const imports =
locationSourceMap.get(realImportPath)?.document
.definitions;
if (imports) {
importedDefinitions = [
...importedDefinitions,
...imports,
];
}
}
}
});
}
const importing = importedDefinitions
.map(def => `\${${getExportedName(def)}}`)
.join('\n');
// is query or mutation
if (definition.kind === Kind.OPERATION_DEFINITION) {
// add for runtime usage
doc.operationName = definition.name.value;
doc.defName = definition.selectionSet.selections
.filter(field => field.kind === Kind.FIELD)
.map(field => field.name.value)
.join(',');
nameLocationMap.set(exportedName, location);
defs.push(`export const ${exportedName} = {
id: '${exportedName}' as const,
operationName: '${doc.operationName}',
definitionName: '${doc.defName}',
query: \`
${print(doc)}${importing || ''}\`,
}
`);
if (definition.operation === 'query') {
queries.push(exportedName);
} else if (definition.operation === 'mutation') {
mutations.push(exportedName);
}
} else {
defs.unshift(`export const ${exportedName} = \`
${print(doc)}${importing || ''}\``);
}
}
}
}
fs.writeFileSync(
output,
[
'/* do not manipulate this file manually. */',
`export interface GraphQLQuery {
id: string
operationName: string
definitionName: string
query: string
}
`,
...defs,
].join('\n')
);
const queriesUnion = queries
.map(query => {
const queryName = upperFirst(query);
return `{
name: '${query}',
variables: ${queryName}Variables,
response: ${queryName}
}
`;
})
.join('|');
const mutationsUnion = mutations
.map(query => {
const queryName = upperFirst(query);
return `{
name: '${query}',
variables: ${queryName}Variables,
response: ${queryName}
}
`;
})
.join('|');
const queryTypes = queriesUnion
? `export type Queries = ${queriesUnion}`
: '';
const mutationsTypes = mutationsUnion
? `export type Mutations = ${mutationsUnion}`
: '';
return `
${queryTypes}
${mutationsTypes}
`;
},
validate: (_schema, _documents, { output }) => {
if (!output) {
throw new Error('Export plugin must be used with a output file given');
}
},
};

View File

@@ -0,0 +1,24 @@
{
"name": "@affine/graphql",
"version": "0.0.0",
"description": "Autogenerated GraphQL client for affine.pro",
"license": "MPL-2.0",
"type": "module",
"main": "./src/index.ts",
"module": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"devDependencies": {
"@graphql-codegen/add": "^4.0.1",
"@graphql-codegen/cli": "3.3.0",
"@graphql-codegen/typescript": "^3.0.3",
"@graphql-codegen/typescript-operations": "^3.0.3",
"@types/lodash-es": "^4.17.7",
"lodash-es": "^4.17.21",
"prettier": "^2.8.7"
},
"dependencies": {
"graphql": "^16.6.0"
}
}

View File

@@ -0,0 +1,111 @@
import type { Mock } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { gqlFetcherFactory } from '../fetcher';
import type { GraphQLQuery } from '../graphql';
const query: GraphQLQuery = {
id: 'query',
query: 'query { field }',
operationName: 'query',
definitionName: 'query',
};
let fetch: Mock;
describe('GraphQL fetcher', () => {
beforeEach(() => {
fetch = vi.fn(() =>
Promise.resolve(
new Response(JSON.stringify({ data: { field: 1 } }), {
headers: {
'content-type': 'application/json',
},
})
)
);
vi.stubGlobal('fetch', fetch);
});
afterEach(() => {
fetch.mockReset();
});
const gql = gqlFetcherFactory('https://example.com/graphql');
it('should send POST request to given endpoint', () => {
gql(
// @ts-expect-error variables is actually optional
{ query }
);
expect(fetch).toBeCalledTimes(1);
expect(fetch.mock.lastCall[0]).toBe('https://example.com/graphql');
const ctx = fetch.mock.lastCall[1] as RequestInit;
expect(ctx.method).toBe('POST');
});
it('should send with correct graphql JSON body', () => {
gql({
query,
// @ts-expect-error forgive the fake variables
variables: { a: 1, b: '2', c: { d: false } },
});
expect(fetch.mock.lastCall[1]).toMatchInlineSnapshot(`
{
"body": "{\\"query\\":\\"query { field }\\",\\"variables\\":{\\"a\\":1,\\"b\\":\\"2\\",\\"c\\":{\\"d\\":false}},\\"operationName\\":\\"query\\"}",
"headers": {
"x-definition-name": "query",
"x-operation-name": "query",
},
"method": "POST",
}
`);
});
it('should correctly ignore nil variables', () => {
gql({
query,
// @ts-expect-error forgive the fake variables
variables: { a: false, b: null, c: undefined },
});
expect(fetch.mock.lastCall[1].body).toMatchInlineSnapshot(
'"{\\"query\\":\\"query { field }\\",\\"variables\\":{\\"a\\":false,\\"b\\":null},\\"operationName\\":\\"query\\"}"'
);
gql({
query,
// @ts-expect-error forgive the fake variables
variables: { a: false, b: null, c: undefined },
keepNilVariables: false,
});
expect(fetch.mock.lastCall[1].body).toMatchInlineSnapshot(
'"{\\"query\\":\\"query { field }\\",\\"variables\\":{\\"a\\":false},\\"operationName\\":\\"query\\"}"'
);
});
it('should correct handle graphql error', async () => {
fetch.mockResolvedValue(
new Response(
JSON.stringify({
data: null,
errors: [{ message: 'error', path: ['field'] }],
}),
{
headers: {
'content-type': 'application/json',
},
status: 400,
}
)
);
await expect(gql({ query, variables: {} })).rejects.toMatchInlineSnapshot(`
[
[GraphQLError: error],
]
`);
});
});

View File

@@ -0,0 +1,186 @@
import type { ExecutionResult } from 'graphql';
import { GraphQLError } from 'graphql';
import { isNil, isObject, merge } from 'lodash-es';
import type { GraphQLQuery } from './graphql';
import type { Mutations, Queries } from './schema';
export type NotArray<T> = T extends Array<unknown> ? never : T;
type _QueryVariables<Q extends GraphQLQuery> = Extract<
Queries | Mutations,
{ name: Q['id'] }
>['variables'];
export type QueryVariables<Q extends GraphQLQuery> = _QueryVariables<Q> extends
| never
| Record<string, never>
? never
: _QueryVariables<Q>;
export type QueryResponse<Q extends GraphQLQuery> = Extract<
Queries | Mutations,
{ name: Q['id'] }
>['response'];
type NullableKeys<T> = {
[K in keyof T]: null extends T[K] ? K : never;
}[keyof T];
type NonNullableKeys<T> = {
[K in keyof T]: null extends T[K] ? never : K;
}[keyof T];
export type RecursiveMaybeFields<T> = T extends
| number
| boolean
| string
| null
| undefined
? T
: {
[K in NullableKeys<T>]?: RecursiveMaybeFields<T[K]>;
} & {
[K in NonNullableKeys<T>]: RecursiveMaybeFields<T[K]>;
};
type AllowedRequestContext = Omit<RequestInit, 'method' | 'body'>;
export interface RequestBody {
operationName?: string;
variables: any;
query: string;
form?: FormData;
}
type QueryVariablesOption<Q extends GraphQLQuery> =
QueryVariables<Q> extends never
? {
variables?: undefined;
}
: { variables: RecursiveMaybeFields<QueryVariables<Q>> };
export type RequestOptions<Q extends GraphQLQuery> = QueryVariablesOption<Q> & {
/**
* parameter passed to `fetch` function
*/
context?: AllowedRequestContext;
/**
* files need to be uploaded
*
* When provided, the request body will be turned to multiparts to satisfy
* file uploading scene.
*/
files?: File[];
/**
* Whether keep null or undefined value in variables.
*
* if `false` given, `{ a: 0, b: undefined, c: null }` will be converted to `{ a: 0 }`
*
* @default true
*/
keepNilVariables?: boolean;
};
export type QueryOptions<Q extends GraphQLQuery> = RequestOptions<Q> & {
query: Q;
};
export type MutationOptions<M extends GraphQLQuery> = RequestOptions<M> & {
mutation: M;
};
function filterEmptyValue(vars: any) {
const newVars: Record<string, any> = {};
Object.entries(vars).forEach(([key, value]) => {
if (isNil(value)) {
return;
}
if (isObject(value) && !(value instanceof File)) {
newVars[key] = filterEmptyValue(value);
return;
}
newVars[key] = value;
});
return newVars;
}
export function appendFormData(body: RequestBody, files: File[]) {
const form = new FormData();
if (body.operationName) {
form.append('operationName', body.operationName);
}
form.append('query', body.query);
form.append('variables', JSON.stringify(body.variables));
files.forEach(file => {
form.append(file.name, file);
});
body.form = form;
}
function formatRequestBody<Q extends GraphQLQuery>({
query,
variables,
keepNilVariables,
files,
}: QueryOptions<Q>): RequestBody {
const body: RequestBody = {
query: query.query,
variables:
keepNilVariables ?? true ? variables : filterEmptyValue(variables),
};
if (query.operationName) {
body.operationName = query.operationName;
}
if (files?.length) {
appendFormData(body, files);
}
return body;
}
export const gqlFetcherFactory = (endpoint: string) => {
const gqlFetch = async <Query extends GraphQLQuery>(
options: QueryOptions<Query>
): Promise<QueryResponse<Query>> => {
const body = formatRequestBody(options);
const ret = fetch(
endpoint,
merge(options.context, {
method: 'POST',
headers: {
'x-operation-name': options.query.operationName,
'x-definition-name': options.query.definitionName,
},
body: body.form ?? JSON.stringify(body),
})
).then(async res => {
if (res.headers.get('content-type') === 'application/json') {
const result = (await res.json()) as ExecutionResult;
if (res.status >= 400) {
if (result.errors && result.errors.length > 0) {
throw result.errors.map(
error => new GraphQLError(error.message, error)
);
} else {
throw new GraphQLError('Empty GraphQL error body');
}
} else if (result.data) {
// we have to cast here because the type of result.data is a union type
return result.data as any;
}
}
throw new GraphQLError('GraphQL query responds unexpected result');
});
return ret;
};
return gqlFetch;
};

View File

@@ -0,0 +1,7 @@
mutation createWorkspace {
createWorkspace {
id
public
created_at
}
}

View File

@@ -0,0 +1,36 @@
/* do not manipulate this file manually. */
export interface GraphQLQuery {
id: string;
operationName: string;
definitionName: string;
query: string;
}
export const createWorkspaceMutation = {
id: 'createWorkspaceMutation' as const,
operationName: 'createWorkspace',
definitionName: 'createWorkspace',
query: `
mutation createWorkspace {
createWorkspace {
id
public
created_at
}
}`,
};
export const workspaceByIdQuery = {
id: 'workspaceByIdQuery' as const,
operationName: 'workspaceById',
definitionName: 'workspace',
query: `
query workspaceById($id: String!) {
workspace(id: $id) {
id
type
public
created_at
}
}`,
};

View File

@@ -0,0 +1,8 @@
query workspaceById($id: String!) {
workspace(id: $id) {
id
type
public
created_at
}
}

View File

@@ -0,0 +1,3 @@
export * from './fetcher';
export * from './graphql';
export * from './schema';

View File

@@ -0,0 +1,69 @@
/* eslint-disable */
export type Maybe<T> = T | null;
export type InputMaybe<T> = T | null;
export type Exact<T extends { [key: string]: unknown }> = {
[K in keyof T]: T[K];
};
export type MakeOptional<T, K extends keyof T> = Omit<T, K> & {
[SubKey in K]?: Maybe<T[SubKey]>;
};
export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & {
[SubKey in K]: Maybe<T[SubKey]>;
};
/** All built-in and custom scalars, mapped to their actual values */
export interface Scalars {
ID: string;
String: string;
Boolean: boolean;
Int: number;
Float: number;
/** A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. */
DateTime: string;
}
/** Workspace type */
export enum WorkspaceType {
/** Normal workspace */
Normal = 'Normal',
/** Private workspace */
Private = 'Private',
}
export type CreateWorkspaceMutationVariables = Exact<{ [key: string]: never }>;
export type CreateWorkspaceMutation = {
__typename?: 'Mutation';
createWorkspace: {
__typename?: 'Workspace';
id: string;
public: boolean;
created_at: string;
};
};
export type WorkspaceByIdQueryVariables = Exact<{
id: Scalars['String'];
}>;
export type WorkspaceByIdQuery = {
__typename?: 'Query';
workspace: {
__typename?: 'Workspace';
id: string;
type: WorkspaceType;
public: boolean;
created_at: string;
};
};
export type Queries = {
name: 'workspaceByIdQuery';
variables: WorkspaceByIdQueryVariables;
response: WorkspaceByIdQuery;
};
export type Mutations = {
name: 'createWorkspaceMutation';
variables: CreateWorkspaceMutationVariables;
response: CreateWorkspaceMutation;
};

View File

@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"rootDir": "./src",
"noEmit": true
},
"include": ["src"]
}

View File

@@ -28,6 +28,7 @@
"@affine/env/*": ["./packages/env/src/*"],
"@affine/utils": ["./packages/utils"],
"@affine/workspace/*": ["./packages/workspace/src/*"],
"@affine/graphql": ["./packages/graphql/src"],
"@affine-test/fixtures/*": ["./tests/fixtures/*"],
"@toeverything/y-indexeddb": ["./packages/y-indexeddb/src"],
"@toeverything/hooks/*": ["./packages/hooks/src/*"]
@@ -55,6 +56,9 @@
{
"path": "./packages/env"
},
{
"path": "./packages/graphql"
},
{
"path": "./packages/debug"
},

1551
yarn.lock

File diff suppressed because it is too large Load Diff