Skip to content

Commit

Permalink
✨ feat: Support GraphQL scalars
Browse files Browse the repository at this point in the history
  • Loading branch information
Snowflyt committed Mar 23, 2024
1 parent e2fc42d commit 0b5ec45
Show file tree
Hide file tree
Showing 18 changed files with 411 additions and 225 deletions.
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,53 @@ const user = await query('user', { id: 1 }).select((user) => [

Whether you can omit arguments or not is validated at compile time, so you can be sure that you will not forget to pass required arguments at runtime.

### Use scalars to transform values

GraphQL provides the ability to define custom scalar types, which should be transformed to a specific type in the client. For example, you can define a `DateTime` scalar type in the GraphQL schema, and you want to transform it to a `Date` object in the client.

```graphql
scalar DateTime

type User {
id: Int!
name: String!
createdAt: DateTime!
}
```

To define a scalar type in the `withSchema` function, you can use the `scalar` function.

```typescript
import { createClient, scalar } from 'graphql-intuitive-request';

const { mutation, query } = createClient('https://example.com/graphql').withSchema({
User: {
id: 'Int!',
name: 'String!',
createdAt: 'DateTime!',
},
CreateUserInput: {
name: 'String!',
createdAt: 'DateTime!',
},
DateTime: scalar<Date>()({
parse: (value) => new Date(value),
serialize: (value) => value.toISOString(),
}),

Query: {
user: [{ id: 'Int!' }, '=>', 'User'],
},
Mutation: {
createUser: [{ input: 'CreateUserInput!' }, '=>', 'User!'],
},
});
```

Then when you query the `createdAt` field, the value will be transformed to a `Date` object. The same is true when you pass a `Date` object to the `createdAt` field in a mutation, the value will be serialized to a string.

`GraphQLScalarType` from `graphql` package can also be used to replace the `scalar` function, so you can import other scalar types from packages like `graphql-scalars`.

### Support for subscriptions

graphql-intuitive-request supports GraphQL subscriptions. You can use `client.subscription` to create a subscription. The usage is similar to `client.query` and `client.mutation`.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "graphql-intuitive-request",
"version": "0.2.0-dev",
"version": "0.2.0",
"private": true,
"description": "Intuitive and (more importantly) TS-friendly GraphQL client for queries, mutations and subscriptions",
"keywords": [
Expand Down
83 changes: 42 additions & 41 deletions src/client.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { GraphQLClient as RequestClient } from 'graphql-request';
import { createClient as createWSClient } from 'graphql-ws';

import { nullableDefinition, refersScalarDefinition } from './definition-utils';
import { buildQueryString } from './query-builder';
import { createAllSelector, parseSelector } from './selector';
import { createTypeParser } from './type-parser';
import { transformScalarRecursively } from './types';
import { capitalize, mapObject, mapObjectValues, omit, pick, requiredKeysCount } from './utils';

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - `schema` is only used in doc
import type { schema } from './types';
import type { MutationFunction, QueryFunction, SubscriptionFunction } from './types/client-tools';
import type { Obj, QueryPromise, SimpleSpread, SubscriptionResponse } from './types/common';
import type { OperationCollection, TypeCollection } from './types/graphql-types';
Expand Down Expand Up @@ -78,16 +78,15 @@ const _createClient = <
const mutations = normalizeOperations((_rawTypes.Mutation as OperationCollection) ?? {});
const subscriptions = normalizeOperations((_rawTypes.Subscription as OperationCollection) ?? {});

const typeParser = createTypeParser($);
const compileOperations = (operations: NormalizedOperations) =>
mapObjectValues(operations, ({ inputType, returnType }) => ({
inputType: mapObject(inputType, ([name, type]) => [
name.replace(/\?$/, ''),
name.endsWith('?') ? typeParser.nullable(type) : type,
name.endsWith('?') ? nullableDefinition(type) : type,
]),
returnType,
hasInput: Object.keys(inputType).length > 0,
isReturnTypeScalar: typeParser.isScalarType(returnType),
isReturnTypeScalar: refersScalarDefinition(returnType, $),
}));

const preCompiledQueries = compileOperations(queries);
Expand All @@ -100,35 +99,32 @@ const _createClient = <
inputType: Record<string, string>,
returnType: string,
input: Record<string, any>,
ast: readonly QueryNode[] | (() => readonly QueryNode[]),
astOrAstFactory: readonly QueryNode[] | (() => readonly QueryNode[]),
): TMethod extends 'subscription' ? SubscriptionResponse<any> : QueryPromise<any> => {
const { queryString, variables } = (() => {
let cached: ReturnType<typeof buildQueryString> | null = null;
const transformedInput = mapObject(input, ([k, v]) => [
k,
transformScalarRecursively(v, inputType[k], $, 'serialize'),
]);
const { getQueryString, getVariables } = (() => {
const getAst = (() => {
let cached: readonly QueryNode[] | null = null;
return () => {
if (cached) return cached;
cached = typeof astOrAstFactory === 'function' ? astOrAstFactory() : astOrAstFactory;
return cached;
};
})();
const getBuildResult = (() => {
let cached: ReturnType<typeof buildQueryString> | null = null;
return () => {
if (cached) return cached;
cached = buildQueryString(method, operationName, inputType, returnType, $, getAst());
return cached;
};
})();
return {
queryString: () => {
if (cached) return cached.queryString;
cached = buildQueryString(
method,
operationName,
inputType,
returnType,
$,
typeof ast === 'function' ? ast() : ast,
);
return cached.queryString;
},
variables: () => {
if (cached) return cached.variables;
cached = buildQueryString(
method,
operationName,
inputType,
returnType,
$,
typeof ast === 'function' ? ast() : ast,
);
return cached.variables;
},
getQueryString: () => getBuildResult().queryString,
getVariables: () => getBuildResult().variables,
};
})();

Expand All @@ -140,7 +136,7 @@ const _createClient = <
onComplete?: () => void,
) =>
wsClient.subscribe(
{ query: queryString(), variables: { ...input, ...variables() } },
{ query: getQueryString(), variables: { ...transformedInput, ...getVariables() } },
{
next: (value) => {
if (value.errors) onError?.(value.errors);
Expand All @@ -154,21 +150,26 @@ const _createClient = <
},
},
),
toQueryString: () => queryString(),
toRequestBody: () => ({ query: queryString(), variables: { ...input, ...variables() } }),
toQueryString: () => getQueryString(),
toRequestBody: () => ({
query: getQueryString(),
variables: { ...transformedInput, ...getVariables() },
}),
} as any;

const result: any = Promise.resolve(null).then(() => {
const result: any = Promise.resolve(null).then(async () => {
if (cancelledPromises.has(result)) return;

return requestClient
.request(queryString(), { ...input, ...variables() })
const res = await requestClient
.request(getQueryString(), { ...transformedInput, ...getVariables() })
.then((data) => (data as any)[operationName]);

return transformScalarRecursively(res, returnType, $, 'parse');
});
result.toQueryString = () => queryString();
result.toQueryString = () => getQueryString();
result.toRequestBody = () => ({
query: queryString(),
variables: { ...input, ...variables() },
query: getQueryString(),
variables: { ...transformedInput, ...getVariables() },
});
return result;
};
Expand Down
38 changes: 38 additions & 0 deletions src/definition-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { GRAPHQL_BASE_TYPES, getTypesEnums, getTypesScalars } from './types';

import type { TypeCollection } from './types/graphql-types';

export const extractCoreDefinition = (type: string): string => {
let result = type;
if (result.endsWith('!')) result = result.slice(0, -1);
if (result.startsWith('[') && result.endsWith(']')) result = result.slice(1, -1);
if (result.endsWith('!')) result = result.slice(0, -1);
return result;
};

export const nullableDefinition = (type: string): string => {
if (type.endsWith('!')) return type.slice(0, -1);
return type;
};

export const refersScalarDefinition = (type: string, $: TypeCollection): boolean => {
const userScalars = getTypesScalars($);
const enums = getTypesEnums($);
const simpleGraphQLTypes = [...GRAPHQL_BASE_TYPES, ...userScalars, ...enums];

let coreDefinition = extractCoreDefinition(type);
if (coreDefinition === 'void') return true;
if (simpleGraphQLTypes.includes(coreDefinition)) return true;
let depth = 0;
const MAXIMUM_DEPTH = 50;
for (; depth < MAXIMUM_DEPTH; depth++) {
const resolved = $[coreDefinition];
if (!resolved) throw new Error(`Unable to resolve type '${coreDefinition}'`);
if (typeof resolved !== 'string') return false;
if (simpleGraphQLTypes.includes(resolved)) return true;
coreDefinition = extractCoreDefinition(resolved);
}
if (depth === MAXIMUM_DEPTH)
throw new Error(`Unable to determine if type '${type}' is scalar (recursion depth exceeded)`);
return false;
};
12 changes: 8 additions & 4 deletions src/query-builder.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { extractCoreDefinition } from './definition-utils';
import { parseSelector } from './selector';
import { createTypeParser } from './type-parser';
import { transformScalarRecursively } from './types';
import { mapObjectValues } from './utils';

import type { ObjectSelector } from './types/ast-builder';
Expand All @@ -26,7 +27,10 @@ const buildQueryAst = (
.map(([key, value]) => {
const variableName = path2variableName([...path, node.key, key]);
const valueType = $[type][node.key as never][0][key] as string;
_variables[variableName] = { type: valueType, value };
_variables[variableName] = {
type: valueType,
value: transformScalarRecursively(value, valueType, $, 'serialize'),
};
return `${key}: $${variableName}`;
})
.join(', ') +
Expand All @@ -39,7 +43,7 @@ const buildQueryAst = (
const { queryString, variables } = buildQueryAst(
node.children as QueryNode[],
indent + 2,
createTypeParser($).extractCoreType(
extractCoreDefinition(
Array.isArray($[type][node.key as never])
? $[type][node.key as never][1]
: $[type][node.key as never],
Expand Down Expand Up @@ -71,7 +75,7 @@ export const buildQueryString = (
const { queryString, variables } = buildQueryAst(
ast,
4,
createTypeParser($).extractCoreType(returnType),
extractCoreDefinition(returnType),
$,
);
return [' ' + queryString, variables];
Expand Down
14 changes: 6 additions & 8 deletions src/selector.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { createTypeParser } from './type-parser';
import { extractCoreDefinition, refersScalarDefinition } from './definition-utils';
import { requiredKeysCount } from './utils';

import type { ObjectSelector, ObjectSelectorBuilder } from './types/ast-builder';
import type { ParseNodes } from './types/ast-parser';
import type { ObjectDefinition, TypeCollection } from './types/graphql-types';
import type { TypeCollection } from './types/graphql-types';
import type { QueryNode } from './types/query-node';

const createBuilder = <T>(): ObjectSelectorBuilder<T> =>
Expand Down Expand Up @@ -33,22 +33,20 @@ export const createAllSelector = <T extends string, $ extends TypeCollection>(
type: T,
$: $,
): any => {
const parser = createTypeParser($);

const spread = (coreType: string) => $[coreType] as ObjectDefinition;
const spread = (coreType: string) => $[coreType];

const buildSelector = (coreType: string) => (o: any) => {
return Object.entries(spread(coreType)).map(([key, value]) => {
if (typeof value !== 'string' && requiredKeysCount(value[0]) > 0)
throw new Error(
`All input fields of '${coreType}.${key}' must be optional to automatically select all fields`,
);
const _coreType = parser.extractCoreType(typeof value === 'string' ? value : value[1]);
return parser.isScalarType(_coreType) ? o[key] : o[key](buildSelector(_coreType));
const _coreType = extractCoreDefinition(typeof value === 'string' ? value : value[1]);
return refersScalarDefinition(_coreType, $) ? o[key] : o[key](buildSelector(_coreType));
});
};

return buildSelector(parser.extractCoreType(type));
return buildSelector(extractCoreDefinition(type));
};

export const parseSelector = (selector: any): readonly QueryNode[] => selector(createBuilder());
Expand Down
45 changes: 0 additions & 45 deletions src/type-parser.ts

This file was deleted.

Loading

0 comments on commit 0b5ec45

Please sign in to comment.