Skip to content

Commit d1a54ff

Browse files
committed
feat(astVisitor): add VisitInfo utility class which returns node info 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`.
1 parent ea3d660 commit d1a54ff

File tree

7 files changed

+454
-98
lines changed

7 files changed

+454
-98
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "graphql-compose-modules",
33
"license": "MIT",
44
"version": "0.0.0-development",
5-
"description": "A toolset for construction GraphQL Schema via file structure",
5+
"description": "A toolkit for construction GraphQL Schema via file structure",
66
"repository": "https://github.com/graphql-compose/graphql-compose-modules",
77
"homepage": "https://github.com/graphql-compose/graphql-compose-modules",
88
"main": "lib/index",

src/VisitInfo.ts

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import {
2+
ComposeNamedOutputType,
3+
ComposeOutputType,
4+
isTypeComposer,
5+
ObjectTypeComposer,
6+
SchemaComposer,
7+
unwrapOutputTC,
8+
upperFirst,
9+
} from 'graphql-compose';
10+
import {
11+
AstDirNode,
12+
AstFileNode,
13+
AstRootNode,
14+
AstRootTypeNode,
15+
RootTypeNames,
16+
} from './directoryToAst';
17+
import { FieldConfig } from './typeDefs';
18+
19+
interface VisitInfoData<TContext = any> {
20+
node: AstDirNode | AstFileNode | AstRootTypeNode;
21+
nodeParent: AstDirNode | AstRootTypeNode | AstRootNode;
22+
operation: RootTypeNames;
23+
fieldName: string;
24+
fieldPath: string[];
25+
schemaComposer: SchemaComposer<TContext>;
26+
}
27+
28+
export class VisitInfo<TContext = any> {
29+
node: AstDirNode | AstFileNode | AstRootTypeNode;
30+
/** Parent AST node from directoryToAst */
31+
nodeParent: AstDirNode | AstRootTypeNode | AstRootNode;
32+
/** Brunch of schema under which is working visitor. Can be: query, mutation, subscription */
33+
operation: RootTypeNames;
34+
/** Name of field for current FieldConfig */
35+
fieldName: string;
36+
/** List of parent names starting from root */
37+
fieldPath: string[];
38+
/** Type registry */
39+
schemaComposer: SchemaComposer<TContext>;
40+
41+
constructor(data: VisitInfoData<TContext>) {
42+
this.node = data.node;
43+
this.operation = data.operation;
44+
this.nodeParent = data.nodeParent;
45+
this.fieldName = data.fieldName;
46+
this.fieldPath = data.fieldPath;
47+
this.schemaComposer = data.schemaComposer;
48+
}
49+
50+
/**
51+
* Check that this entrypoint belongs to Query
52+
*/
53+
isQuery(): boolean {
54+
return this.operation === 'query';
55+
}
56+
57+
/**
58+
* Check that this entrypoint belongs to Mutation
59+
*/
60+
isMutation(): boolean {
61+
return this.operation === 'mutation';
62+
}
63+
64+
/**
65+
* Check that this entrypoint belongs to Subscription
66+
*/
67+
isSubscription(): boolean {
68+
return this.operation === 'subscription';
69+
}
70+
71+
/**
72+
* Return array of fieldNames.
73+
* Dotted names will be automatically splitted.
74+
*
75+
* @example
76+
* Assume:
77+
* name: 'ping'
78+
* path: ['query.storage', 'viewer', 'utils.debug']
79+
* For empty options will be returned:
80+
* ['storage', 'viewer', 'utils', 'debug', 'ping']
81+
* For `{ includeOperation: true }` will be returned:
82+
* ['query', 'storage', 'viewer', 'utils', 'debug', 'ping']
83+
*/
84+
getFieldPathArray(opts?: { includeOperation?: boolean; omitFieldName?: boolean }): string[] {
85+
const res = [] as string[];
86+
this.fieldPath.forEach((e) => {
87+
if (e.indexOf('.')) {
88+
res.push(...e.split('.').filter(Boolean));
89+
} else {
90+
res.push(e);
91+
}
92+
});
93+
94+
if (!opts?.omitFieldName) {
95+
res.push(this.fieldName);
96+
}
97+
98+
return opts?.includeOperation ? res : res.slice(1);
99+
}
100+
101+
/**
102+
* Return dotted path for current field
103+
*/
104+
getFieldPathDotted(opts?: { includeOperation?: boolean; omitFieldName?: boolean }): string {
105+
return this.getFieldPathArray(opts).join('.');
106+
}
107+
108+
/**
109+
* Return path as CamelCase string.
110+
*
111+
* Useful for getting type name according to path
112+
*/
113+
getFieldPathCamelCase(opts?: { includeOperation?: boolean; omitFieldName?: boolean }): string {
114+
return this.getFieldPathArray(opts)
115+
.map((s) => upperFirst(s))
116+
.join('');
117+
}
118+
119+
/**
120+
* Get FieldConfig for file or dir.
121+
* This is mutable object and is shared between all calls.
122+
*/
123+
get fieldConfig(): FieldConfig {
124+
if (this.node.kind === 'file') {
125+
return this.node.code?.default as FieldConfig;
126+
} else if (this.node.kind === 'dir' || this.node.kind === 'rootType') {
127+
return this.node.namespaceConfig?.code?.default as FieldConfig;
128+
}
129+
throw new Error(
130+
`Cannot get fieldConfig. Node has some strange kind: ${(this.node as any).kind}`
131+
);
132+
}
133+
134+
/**
135+
* Get TypeComposer instance for output type (object, scalar, enum, interface, union).
136+
* It's mutable object.
137+
*/
138+
getOutputAnyTC(): ComposeOutputType<TContext> {
139+
const fc = this.fieldConfig;
140+
const outputType = fc.type;
141+
if (!outputType) {
142+
throw new Error(`FieldConfig ${this.getFieldPathDotted()} does not have 'type' property`);
143+
}
144+
145+
// if the type is of any kind of TypeComposer
146+
// then return it directly
147+
// or try to convert it to TypeComposer and save in FieldConfig as prepared type
148+
if (isTypeComposer(outputType)) {
149+
return outputType;
150+
} else {
151+
const outputTC = this.schemaComposer.typeMapper.convertOutputTypeDefinition(
152+
outputType,
153+
this.fieldName,
154+
this.nodeParent?.name
155+
);
156+
157+
if (!outputTC) {
158+
throw new Error(
159+
`FieldConfig ${this.getFieldPathDotted()} contains some strange value as output type`
160+
);
161+
}
162+
163+
fc.type = outputTC;
164+
return outputTC;
165+
}
166+
}
167+
168+
/**
169+
* Check that output type is an object
170+
*/
171+
isOutputTypeIsObject(): boolean {
172+
return this.getOutputAnyTC() instanceof ObjectTypeComposer;
173+
}
174+
175+
/**
176+
* Get TypeComposer instance for output type (object, scalar, enum, interface, union).
177+
* It's mutable object.
178+
*/
179+
getOutputUnwrappedTC(): ComposeNamedOutputType<TContext> {
180+
return unwrapOutputTC(this.getOutputAnyTC());
181+
}
182+
183+
/**
184+
* Get TypeComposer instance for output type (object, scalar, enum, interface, union).
185+
* It's mutable object.
186+
*/
187+
getOutputUnwrappedOTC(): ObjectTypeComposer {
188+
const tc = unwrapOutputTC(this.getOutputAnyTC());
189+
190+
if (!(tc instanceof ObjectTypeComposer)) {
191+
throw new Error(
192+
`FieldConfig ${this.getFieldPathDotted()} has non-Object output type. Use 'isOutputTypeIsObject()' before for avoiding this error.`
193+
);
194+
}
195+
196+
return tc;
197+
}
198+
199+
toString(): string {
200+
return `VisitInfo(${this.getFieldPathDotted({ includeOperation: true })})`;
201+
}
202+
203+
toJSON(): string {
204+
return this.toString();
205+
}
206+
}

src/__tests__/VisitInfo-test.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import {
2+
ListComposer,
3+
ObjectTypeComposer,
4+
ScalarTypeComposer,
5+
SchemaComposer,
6+
} from 'graphql-compose';
7+
import { AstFileNode, AstRootNode, VisitInfo } from '..';
8+
9+
const schemaComposer = new SchemaComposer();
10+
const nodeParent = {
11+
absPath: 'schema/query',
12+
children: {},
13+
kind: 'root',
14+
name: 'query',
15+
} as AstRootNode;
16+
const node = {
17+
absPath: 'schema/query/some_endpoint.ts',
18+
code: {
19+
default: {
20+
type: 'String',
21+
resolve: () => 'Hello!',
22+
},
23+
},
24+
kind: 'file',
25+
name: 'some_endpoint',
26+
} as AstFileNode;
27+
28+
beforeEach(() => {
29+
schemaComposer.clear();
30+
});
31+
32+
describe('VisitInfo', () => {
33+
it('getFieldPathArray()', () => {
34+
const info = new VisitInfo({
35+
operation: 'query',
36+
fieldName: 'ping',
37+
fieldPath: ['query.storage', 'viewer', 'utils.debug'],
38+
node,
39+
nodeParent,
40+
schemaComposer,
41+
});
42+
43+
expect(info.getFieldPathArray()).toEqual(['storage', 'viewer', 'utils', 'debug', 'ping']);
44+
expect(info.getFieldPathArray({ omitFieldName: true })).toEqual([
45+
'storage',
46+
'viewer',
47+
'utils',
48+
'debug',
49+
]);
50+
expect(info.getFieldPathArray({ includeOperation: true })).toEqual([
51+
'query',
52+
'storage',
53+
'viewer',
54+
'utils',
55+
'debug',
56+
'ping',
57+
]);
58+
});
59+
60+
it('getFieldPathDotted()', () => {
61+
const info = new VisitInfo({
62+
operation: 'query',
63+
fieldName: 'ping',
64+
fieldPath: ['query.storage', 'viewer', 'utils.debug'],
65+
node,
66+
nodeParent,
67+
schemaComposer,
68+
});
69+
70+
expect(info.getFieldPathDotted()).toEqual('storage.viewer.utils.debug.ping');
71+
expect(info.getFieldPathDotted({ omitFieldName: true })).toEqual('storage.viewer.utils.debug');
72+
expect(info.getFieldPathDotted({ includeOperation: true })).toEqual(
73+
'query.storage.viewer.utils.debug.ping'
74+
);
75+
});
76+
77+
it('getFieldPathCamelCase()', () => {
78+
const info = new VisitInfo({
79+
operation: 'query',
80+
fieldName: 'ping',
81+
fieldPath: ['query.storage', 'viewer', 'utils.debug'],
82+
node,
83+
nodeParent,
84+
schemaComposer,
85+
});
86+
87+
expect(info.getFieldPathCamelCase()).toEqual('StorageViewerUtilsDebugPing');
88+
expect(info.getFieldPathCamelCase({ omitFieldName: true })).toEqual('StorageViewerUtilsDebug');
89+
expect(info.getFieldPathCamelCase({ includeOperation: true })).toEqual(
90+
'QueryStorageViewerUtilsDebugPing'
91+
);
92+
});
93+
94+
it('get fieldConfig', () => {
95+
const info = new VisitInfo({
96+
operation: 'query',
97+
fieldName: 'ping',
98+
fieldPath: ['query.storage', 'viewer', 'utils.debug'],
99+
node,
100+
nodeParent,
101+
schemaComposer,
102+
});
103+
104+
const { fieldConfig } = info;
105+
expect(fieldConfig).toEqual({ resolve: expect.anything(), type: 'String' });
106+
});
107+
108+
describe('methods for output type', () => {
109+
const info = new VisitInfo({
110+
operation: 'query',
111+
fieldName: 'ping',
112+
fieldPath: ['query.storage', 'viewer', 'utils.debug'],
113+
node,
114+
nodeParent,
115+
schemaComposer,
116+
});
117+
118+
it('getOutputAnyTC() with Scalar', () => {
119+
const tc = info.getOutputAnyTC();
120+
expect(tc instanceof ScalarTypeComposer).toBeTruthy();
121+
expect(tc.getTypeName()).toEqual('String');
122+
});
123+
124+
it('getOutputAnyTC() with List', () => {
125+
info.fieldConfig.type = '[String!]';
126+
const tc = info.getOutputAnyTC();
127+
expect(tc instanceof ListComposer).toBeTruthy();
128+
expect(tc.getTypeName()).toEqual('[String!]');
129+
});
130+
131+
it('isOutputTypeIsObject()', () => {
132+
info.fieldConfig.type = 'String';
133+
expect(info.isOutputTypeIsObject()).toBeFalsy();
134+
info.fieldConfig.type = '[String!]';
135+
expect(info.isOutputTypeIsObject()).toBeFalsy();
136+
info.fieldConfig.type = 'type MyObj { a: Int }';
137+
expect(info.isOutputTypeIsObject()).toBeTruthy();
138+
});
139+
140+
it('getOutputUnwrappedTC()', () => {
141+
info.fieldConfig.type = 'String';
142+
expect(info.getOutputUnwrappedTC() instanceof ScalarTypeComposer).toBeTruthy();
143+
expect(info.getOutputUnwrappedTC().getTypeName()).toBe('String');
144+
info.fieldConfig.type = '[String!]';
145+
expect(info.getOutputUnwrappedTC() instanceof ScalarTypeComposer).toBeTruthy();
146+
expect(info.getOutputUnwrappedTC().getTypeName()).toBe('String');
147+
info.fieldConfig.type = ['type MyObj { a: Int }'];
148+
expect(info.getOutputUnwrappedTC() instanceof ObjectTypeComposer).toBeTruthy();
149+
expect(info.getOutputUnwrappedTC().getTypeName()).toBe('MyObj');
150+
});
151+
152+
it('getOutputUnwrappedTC()', () => {
153+
info.fieldConfig.type = 'String';
154+
expect(() => info.getOutputUnwrappedOTC()).toThrowError(/has non-Object output type/);
155+
156+
info.fieldConfig.type = ['type MyObj { a: Int }'];
157+
expect(info.getOutputUnwrappedOTC() instanceof ObjectTypeComposer).toBeTruthy();
158+
expect(info.getOutputUnwrappedOTC().getTypeName()).toBe('MyObj');
159+
});
160+
});
161+
});

0 commit comments

Comments
 (0)