mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18: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:
@@ -12,6 +12,7 @@
|
||||
"component",
|
||||
"workspace",
|
||||
"env",
|
||||
"graphql",
|
||||
"cli",
|
||||
"hooks",
|
||||
"i18n",
|
||||
|
||||
@@ -75,6 +75,12 @@ const config = {
|
||||
'@typescript-eslint/consistent-type-imports': 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: '*.cjs',
|
||||
rules: {
|
||||
'@typescript-eslint/no-var-requires': 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
"**/dist/**"
|
||||
],
|
||||
"env": {
|
||||
"TS_NODE_TRANSPILE_ONLY": true,
|
||||
"TS_NODE_PROJECT": "./tsconfig.json",
|
||||
"NODE_ENV": "development",
|
||||
"DEBUG": "affine:*",
|
||||
|
||||
@@ -66,4 +66,4 @@ export class ConfigModule {
|
||||
};
|
||||
}
|
||||
|
||||
export { AFFiNEConfig } from './def';
|
||||
export type { AFFiNEConfig } from './def';
|
||||
|
||||
@@ -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",
|
||||
|
||||
139
apps/web/src/shared/__tests__/gql.spec.tsx
Normal file
139
apps/web/src/shared/__tests__/gql.spec.tsx
Normal 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
109
apps/web/src/shared/gql.ts
Normal 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;
|
||||
3
packages/graphql/README.md
Normal file
3
packages/graphql/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# `GraphQL` client
|
||||
|
||||
Auto generated `GraphQL` client for affine.pro
|
||||
31
packages/graphql/codegen.yml
Normal file
31
packages/graphql/codegen.yml
Normal 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
|
||||
207
packages/graphql/export-gql-plugin.cjs
Normal file
207
packages/graphql/export-gql-plugin.cjs
Normal 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');
|
||||
}
|
||||
},
|
||||
};
|
||||
24
packages/graphql/package.json
Normal file
24
packages/graphql/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
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;
|
||||
};
|
||||
10
packages/graphql/tsconfig.json
Normal file
10
packages/graphql/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"rootDir": "./src",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user