Skip to content

Commit

Permalink
feat(astVisitor): add VisitInfo utility class which returns node in…
Browse files Browse the repository at this point in the history
…fo and useful methods for writing middlewares

BREAKING CHANGE: `astVisitor` function now requires `schemaComposer` as the second argument. `VisitKindFn` now provide just one new argument `info: VisitInfo`.
  • Loading branch information
nodkz committed Oct 6, 2021
1 parent ea3d660 commit d1a54ff
Show file tree
Hide file tree
Showing 7 changed files with 454 additions and 98 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "graphql-compose-modules",
"license": "MIT",
"version": "0.0.0-development",
"description": "A toolset for construction GraphQL Schema via file structure",
"description": "A toolkit for construction GraphQL Schema via file structure",
"repository": "https://github.com/graphql-compose/graphql-compose-modules",
"homepage": "https://github.com/graphql-compose/graphql-compose-modules",
"main": "lib/index",
Expand Down
206 changes: 206 additions & 0 deletions src/VisitInfo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import {
ComposeNamedOutputType,
ComposeOutputType,
isTypeComposer,
ObjectTypeComposer,
SchemaComposer,
unwrapOutputTC,
upperFirst,
} from 'graphql-compose';
import {
AstDirNode,
AstFileNode,
AstRootNode,
AstRootTypeNode,
RootTypeNames,
} from './directoryToAst';
import { FieldConfig } from './typeDefs';

interface VisitInfoData<TContext = any> {
node: AstDirNode | AstFileNode | AstRootTypeNode;
nodeParent: AstDirNode | AstRootTypeNode | AstRootNode;
operation: RootTypeNames;
fieldName: string;
fieldPath: string[];
schemaComposer: SchemaComposer<TContext>;
}

export class VisitInfo<TContext = any> {
node: AstDirNode | AstFileNode | AstRootTypeNode;
/** Parent AST node from directoryToAst */
nodeParent: AstDirNode | AstRootTypeNode | AstRootNode;
/** Brunch of schema under which is working visitor. Can be: query, mutation, subscription */
operation: RootTypeNames;
/** Name of field for current FieldConfig */
fieldName: string;
/** List of parent names starting from root */
fieldPath: string[];
/** Type registry */
schemaComposer: SchemaComposer<TContext>;

constructor(data: VisitInfoData<TContext>) {
this.node = data.node;
this.operation = data.operation;
this.nodeParent = data.nodeParent;
this.fieldName = data.fieldName;
this.fieldPath = data.fieldPath;
this.schemaComposer = data.schemaComposer;
}

/**
* Check that this entrypoint belongs to Query
*/
isQuery(): boolean {
return this.operation === 'query';
}

/**
* Check that this entrypoint belongs to Mutation
*/
isMutation(): boolean {
return this.operation === 'mutation';
}

/**
* Check that this entrypoint belongs to Subscription
*/
isSubscription(): boolean {
return this.operation === 'subscription';
}

/**
* Return array of fieldNames.
* Dotted names will be automatically splitted.
*
* @example
* Assume:
* name: 'ping'
* path: ['query.storage', 'viewer', 'utils.debug']
* For empty options will be returned:
* ['storage', 'viewer', 'utils', 'debug', 'ping']
* For `{ includeOperation: true }` will be returned:
* ['query', 'storage', 'viewer', 'utils', 'debug', 'ping']
*/
getFieldPathArray(opts?: { includeOperation?: boolean; omitFieldName?: boolean }): string[] {
const res = [] as string[];
this.fieldPath.forEach((e) => {
if (e.indexOf('.')) {
res.push(...e.split('.').filter(Boolean));
} else {
res.push(e);
}
});

if (!opts?.omitFieldName) {
res.push(this.fieldName);
}

return opts?.includeOperation ? res : res.slice(1);
}

/**
* Return dotted path for current field
*/
getFieldPathDotted(opts?: { includeOperation?: boolean; omitFieldName?: boolean }): string {
return this.getFieldPathArray(opts).join('.');
}

/**
* Return path as CamelCase string.
*
* Useful for getting type name according to path
*/
getFieldPathCamelCase(opts?: { includeOperation?: boolean; omitFieldName?: boolean }): string {
return this.getFieldPathArray(opts)
.map((s) => upperFirst(s))
.join('');
}

/**
* Get FieldConfig for file or dir.
* This is mutable object and is shared between all calls.
*/
get fieldConfig(): FieldConfig {
if (this.node.kind === 'file') {
return this.node.code?.default as FieldConfig;
} else if (this.node.kind === 'dir' || this.node.kind === 'rootType') {
return this.node.namespaceConfig?.code?.default as FieldConfig;
}
throw new Error(
`Cannot get fieldConfig. Node has some strange kind: ${(this.node as any).kind}`
);
}

/**
* Get TypeComposer instance for output type (object, scalar, enum, interface, union).
* It's mutable object.
*/
getOutputAnyTC(): ComposeOutputType<TContext> {
const fc = this.fieldConfig;
const outputType = fc.type;
if (!outputType) {
throw new Error(`FieldConfig ${this.getFieldPathDotted()} does not have 'type' property`);
}

// if the type is of any kind of TypeComposer
// then return it directly
// or try to convert it to TypeComposer and save in FieldConfig as prepared type
if (isTypeComposer(outputType)) {
return outputType;
} else {
const outputTC = this.schemaComposer.typeMapper.convertOutputTypeDefinition(
outputType,
this.fieldName,
this.nodeParent?.name
);

if (!outputTC) {
throw new Error(
`FieldConfig ${this.getFieldPathDotted()} contains some strange value as output type`
);
}

fc.type = outputTC;
return outputTC;
}
}

/**
* Check that output type is an object
*/
isOutputTypeIsObject(): boolean {
return this.getOutputAnyTC() instanceof ObjectTypeComposer;
}

/**
* Get TypeComposer instance for output type (object, scalar, enum, interface, union).
* It's mutable object.
*/
getOutputUnwrappedTC(): ComposeNamedOutputType<TContext> {
return unwrapOutputTC(this.getOutputAnyTC());
}

/**
* Get TypeComposer instance for output type (object, scalar, enum, interface, union).
* It's mutable object.
*/
getOutputUnwrappedOTC(): ObjectTypeComposer {
const tc = unwrapOutputTC(this.getOutputAnyTC());

if (!(tc instanceof ObjectTypeComposer)) {
throw new Error(
`FieldConfig ${this.getFieldPathDotted()} has non-Object output type. Use 'isOutputTypeIsObject()' before for avoiding this error.`
);
}

return tc;
}

toString(): string {
return `VisitInfo(${this.getFieldPathDotted({ includeOperation: true })})`;
}

toJSON(): string {
return this.toString();
}
}
161 changes: 161 additions & 0 deletions src/__tests__/VisitInfo-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import {
ListComposer,
ObjectTypeComposer,
ScalarTypeComposer,
SchemaComposer,
} from 'graphql-compose';
import { AstFileNode, AstRootNode, VisitInfo } from '..';

const schemaComposer = new SchemaComposer();
const nodeParent = {
absPath: 'schema/query',
children: {},
kind: 'root',
name: 'query',
} as AstRootNode;
const node = {
absPath: 'schema/query/some_endpoint.ts',
code: {
default: {
type: 'String',
resolve: () => 'Hello!',
},
},
kind: 'file',
name: 'some_endpoint',
} as AstFileNode;

beforeEach(() => {
schemaComposer.clear();
});

describe('VisitInfo', () => {
it('getFieldPathArray()', () => {
const info = new VisitInfo({
operation: 'query',
fieldName: 'ping',
fieldPath: ['query.storage', 'viewer', 'utils.debug'],
node,
nodeParent,
schemaComposer,
});

expect(info.getFieldPathArray()).toEqual(['storage', 'viewer', 'utils', 'debug', 'ping']);
expect(info.getFieldPathArray({ omitFieldName: true })).toEqual([
'storage',
'viewer',
'utils',
'debug',
]);
expect(info.getFieldPathArray({ includeOperation: true })).toEqual([
'query',
'storage',
'viewer',
'utils',
'debug',
'ping',
]);
});

it('getFieldPathDotted()', () => {
const info = new VisitInfo({
operation: 'query',
fieldName: 'ping',
fieldPath: ['query.storage', 'viewer', 'utils.debug'],
node,
nodeParent,
schemaComposer,
});

expect(info.getFieldPathDotted()).toEqual('storage.viewer.utils.debug.ping');
expect(info.getFieldPathDotted({ omitFieldName: true })).toEqual('storage.viewer.utils.debug');
expect(info.getFieldPathDotted({ includeOperation: true })).toEqual(
'query.storage.viewer.utils.debug.ping'
);
});

it('getFieldPathCamelCase()', () => {
const info = new VisitInfo({
operation: 'query',
fieldName: 'ping',
fieldPath: ['query.storage', 'viewer', 'utils.debug'],
node,
nodeParent,
schemaComposer,
});

expect(info.getFieldPathCamelCase()).toEqual('StorageViewerUtilsDebugPing');
expect(info.getFieldPathCamelCase({ omitFieldName: true })).toEqual('StorageViewerUtilsDebug');
expect(info.getFieldPathCamelCase({ includeOperation: true })).toEqual(
'QueryStorageViewerUtilsDebugPing'
);
});

it('get fieldConfig', () => {
const info = new VisitInfo({
operation: 'query',
fieldName: 'ping',
fieldPath: ['query.storage', 'viewer', 'utils.debug'],
node,
nodeParent,
schemaComposer,
});

const { fieldConfig } = info;
expect(fieldConfig).toEqual({ resolve: expect.anything(), type: 'String' });
});

describe('methods for output type', () => {
const info = new VisitInfo({
operation: 'query',
fieldName: 'ping',
fieldPath: ['query.storage', 'viewer', 'utils.debug'],
node,
nodeParent,
schemaComposer,
});

it('getOutputAnyTC() with Scalar', () => {
const tc = info.getOutputAnyTC();
expect(tc instanceof ScalarTypeComposer).toBeTruthy();
expect(tc.getTypeName()).toEqual('String');
});

it('getOutputAnyTC() with List', () => {
info.fieldConfig.type = '[String!]';
const tc = info.getOutputAnyTC();
expect(tc instanceof ListComposer).toBeTruthy();
expect(tc.getTypeName()).toEqual('[String!]');
});

it('isOutputTypeIsObject()', () => {
info.fieldConfig.type = 'String';
expect(info.isOutputTypeIsObject()).toBeFalsy();
info.fieldConfig.type = '[String!]';
expect(info.isOutputTypeIsObject()).toBeFalsy();
info.fieldConfig.type = 'type MyObj { a: Int }';
expect(info.isOutputTypeIsObject()).toBeTruthy();
});

it('getOutputUnwrappedTC()', () => {
info.fieldConfig.type = 'String';
expect(info.getOutputUnwrappedTC() instanceof ScalarTypeComposer).toBeTruthy();
expect(info.getOutputUnwrappedTC().getTypeName()).toBe('String');
info.fieldConfig.type = '[String!]';
expect(info.getOutputUnwrappedTC() instanceof ScalarTypeComposer).toBeTruthy();
expect(info.getOutputUnwrappedTC().getTypeName()).toBe('String');
info.fieldConfig.type = ['type MyObj { a: Int }'];
expect(info.getOutputUnwrappedTC() instanceof ObjectTypeComposer).toBeTruthy();
expect(info.getOutputUnwrappedTC().getTypeName()).toBe('MyObj');
});

it('getOutputUnwrappedTC()', () => {
info.fieldConfig.type = 'String';
expect(() => info.getOutputUnwrappedOTC()).toThrowError(/has non-Object output type/);

info.fieldConfig.type = ['type MyObj { a: Int }'];
expect(info.getOutputUnwrappedOTC() instanceof ObjectTypeComposer).toBeTruthy();
expect(info.getOutputUnwrappedOTC().getTypeName()).toBe('MyObj');
});
});
});
Loading

0 comments on commit d1a54ff

Please sign in to comment.