mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 05:14:54 +00:00
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:
111
packages/graphql/src/__tests__/fetcher.spec.ts
Normal file
111
packages/graphql/src/__tests__/fetcher.spec.ts
Normal 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],
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
186
packages/graphql/src/fetcher.ts
Normal file
186
packages/graphql/src/fetcher.ts
Normal 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;
|
||||
};
|
||||
7
packages/graphql/src/graphql/create-workspace.gql
Normal file
7
packages/graphql/src/graphql/create-workspace.gql
Normal file
@@ -0,0 +1,7 @@
|
||||
mutation createWorkspace {
|
||||
createWorkspace {
|
||||
id
|
||||
public
|
||||
created_at
|
||||
}
|
||||
}
|
||||
36
packages/graphql/src/graphql/index.ts
Normal file
36
packages/graphql/src/graphql/index.ts
Normal 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
|
||||
}
|
||||
}`,
|
||||
};
|
||||
8
packages/graphql/src/graphql/workspace.gql
Normal file
8
packages/graphql/src/graphql/workspace.gql
Normal file
@@ -0,0 +1,8 @@
|
||||
query workspaceById($id: String!) {
|
||||
workspace(id: $id) {
|
||||
id
|
||||
type
|
||||
public
|
||||
created_at
|
||||
}
|
||||
}
|
||||
3
packages/graphql/src/index.ts
Normal file
3
packages/graphql/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './fetcher';
|
||||
export * from './graphql';
|
||||
export * from './schema';
|
||||
69
packages/graphql/src/schema.ts
Normal file
69
packages/graphql/src/schema.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user