mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-23 20:18:42 +08: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:
co-authored by
forehalo
Himself65
parent
4528df07a5
commit
5c673a8ffc
@@ -12,6 +12,7 @@
|
|||||||
"component",
|
"component",
|
||||||
"workspace",
|
"workspace",
|
||||||
"env",
|
"env",
|
||||||
|
"graphql",
|
||||||
"cli",
|
"cli",
|
||||||
"hooks",
|
"hooks",
|
||||||
"i18n",
|
"i18n",
|
||||||
|
|||||||
@@ -75,6 +75,12 @@ const config = {
|
|||||||
'@typescript-eslint/consistent-type-imports': 0,
|
'@typescript-eslint/consistent-type-imports': 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
files: '*.cjs',
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-var-requires': 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@
|
|||||||
"**/dist/**"
|
"**/dist/**"
|
||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
|
"TS_NODE_TRANSPILE_ONLY": true,
|
||||||
"TS_NODE_PROJECT": "./tsconfig.json",
|
"TS_NODE_PROJECT": "./tsconfig.json",
|
||||||
"NODE_ENV": "development",
|
"NODE_ENV": "development",
|
||||||
"DEBUG": "affine:*",
|
"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/component": "workspace:*",
|
||||||
"@affine/debug": "workspace:*",
|
"@affine/debug": "workspace:*",
|
||||||
"@affine/env": "workspace:*",
|
"@affine/env": "workspace:*",
|
||||||
|
"@affine/graphql": "workspace:*",
|
||||||
"@affine/i18n": "workspace:*",
|
"@affine/i18n": "workspace:*",
|
||||||
"@affine/jotai": "workspace:*",
|
"@affine/jotai": "workspace:*",
|
||||||
"@affine/templates": "workspace:*",
|
"@affine/templates": "workspace:*",
|
||||||
@@ -36,6 +37,7 @@
|
|||||||
"cmdk": "^0.2.0",
|
"cmdk": "^0.2.0",
|
||||||
"css-spring": "^4.1.0",
|
"css-spring": "^4.1.0",
|
||||||
"dayjs": "^1.11.7",
|
"dayjs": "^1.11.7",
|
||||||
|
"graphql": "^16.6.0",
|
||||||
"jotai": "^2.0.4",
|
"jotai": "^2.0.4",
|
||||||
"jotai-devtools": "^0.4.0",
|
"jotai-devtools": "^0.4.0",
|
||||||
"lit": "^2.7.2",
|
"lit": "^2.7.2",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# `GraphQL` client
|
||||||
|
|
||||||
|
Auto generated `GraphQL` client for affine.pro
|
||||||
@@ -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
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
|
]
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
mutation createWorkspace {
|
||||||
|
createWorkspace {
|
||||||
|
id
|
||||||
|
public
|
||||||
|
created_at
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
query workspaceById($id: String!) {
|
||||||
|
workspace(id: $id) {
|
||||||
|
id
|
||||||
|
type
|
||||||
|
public
|
||||||
|
created_at
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './fetcher';
|
||||||
|
export * from './graphql';
|
||||||
|
export * from './schema';
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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/env/*": ["./packages/env/src/*"],
|
||||||
"@affine/utils": ["./packages/utils"],
|
"@affine/utils": ["./packages/utils"],
|
||||||
"@affine/workspace/*": ["./packages/workspace/src/*"],
|
"@affine/workspace/*": ["./packages/workspace/src/*"],
|
||||||
|
"@affine/graphql": ["./packages/graphql/src"],
|
||||||
"@affine-test/fixtures/*": ["./tests/fixtures/*"],
|
"@affine-test/fixtures/*": ["./tests/fixtures/*"],
|
||||||
"@toeverything/y-indexeddb": ["./packages/y-indexeddb/src"],
|
"@toeverything/y-indexeddb": ["./packages/y-indexeddb/src"],
|
||||||
"@toeverything/hooks/*": ["./packages/hooks/src/*"]
|
"@toeverything/hooks/*": ["./packages/hooks/src/*"]
|
||||||
@@ -55,6 +56,9 @@
|
|||||||
{
|
{
|
||||||
"path": "./packages/env"
|
"path": "./packages/env"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "./packages/graphql"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "./packages/debug"
|
"path": "./packages/debug"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user