Skip to content

Commit

Permalink
feat(core): introduce GraphQL normalizer
Browse files Browse the repository at this point in the history
closes #234
  • Loading branch information
ostridm committed Mar 28, 2024
1 parent ea56eb3 commit a7a540f
Show file tree
Hide file tree
Showing 7 changed files with 2,948 additions and 37 deletions.
14 changes: 6 additions & 8 deletions packages/core/src/importers/GraphQLImporter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { BaseImporter } from './BaseImporter';
import { ImporterType } from './ImporterType';
import { GraphQLNormalizer } from './GraphQLNormalizer';
import { isArrayOfStrings } from '../utils';
import { type DocFormat, type Spec } from './Spec';
import { GraphQL, introspectionFromSchema } from '../types';
Expand All @@ -12,7 +13,7 @@ export class GraphQLImporter extends BaseImporter<ImporterType.GRAPHQL> {
return ImporterType.GRAPHQL;
}

constructor() {
constructor(private readonly normalizer = new GraphQLNormalizer()) {
super();
}

Expand All @@ -36,9 +37,7 @@ export class GraphQLImporter extends BaseImporter<ImporterType.GRAPHQL> {
return Promise.resolve(undefined);
}

public isSupported(
spec: unknown
): spec is GraphQL.Document {
public isSupported(spec: unknown): spec is GraphQL.Document {
return (
this.isGraphQLSDLEnvelope(spec) ||
this.isGraphQlIntrospectionEnvelope(spec)
Expand All @@ -65,13 +64,13 @@ export class GraphQLImporter extends BaseImporter<ImporterType.GRAPHQL> {
loaders: []
});

return {
obj = {
...obj,
data: introspectionFromSchema(schema)
};
}

return obj;
return this.normalizer.normalize(obj);
}

private isGraphQLSDLEnvelope(
Expand All @@ -96,8 +95,7 @@ export class GraphQLImporter extends BaseImporter<ImporterType.GRAPHQL> {
typeof (obj as GraphQL.Document).url === 'string' &&
'data' in obj &&
'__schema' in (obj as GraphQL.Document).data &&
typeof (obj as GraphQL.Document).data.__schema ===
'object'
typeof (obj as GraphQL.Document).data.__schema === 'object'
);
}

Expand Down
122 changes: 122 additions & 0 deletions packages/core/src/importers/GraphQLNormalizer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import {
GraphQL,
IntrospectionDirective,
IntrospectionField,
IntrospectionInputValue,
IntrospectionInterfaceType,
IntrospectionObjectType,
IntrospectionType,
IntrospectionNamedTypeRef,
IntrospectionSchema
} from '../types';

type DeepWriteable<T> = { -readonly [P in keyof T]: DeepWriteable<T[P]> };
type DeepPartial<T> = { [P in keyof T]?: DeepPartial<T[P]> };
type DpDw<T> = DeepPartial<DeepWriteable<T>>;

export class GraphQLNormalizer {
public normalize(input: GraphQL.Document): GraphQL.Document {
const schema = (input as DpDw<GraphQL.Document>).data?.__schema;

if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
return input;
}

const result = JSON.parse(JSON.stringify(input)) as DpDw<GraphQL.Document>;

this.normalizeSchema(result.data.__schema);

return result as GraphQL.Document;
}

private normalizeSchema(
schema: DeepPartial<DeepWriteable<IntrospectionSchema>>
) {
this.normalizeTypeRef(schema.queryType);
this.normalizeTypeRef(schema.mutationType);
this.normalizeTypeRef(schema.subscriptionType);

this.normalizeDirectives(schema);
this.normalizeTypes(schema);
}

private normalizeTypeRef(
typeRef: DpDw<IntrospectionNamedTypeRef<IntrospectionObjectType>>
) {
if (!!typeRef && typeof typeRef === 'object' && !Array.isArray(typeRef)) {
typeRef.kind ??= 'OBJECT';
}
}

private normalizeTypes(obj: { types?: DpDw<IntrospectionType>[] }): void {
obj.types ??= [];
if (Array.isArray(obj.types)) {
obj.types.forEach((t) => this.normalizeType(t));
}
}

private normalizeType(type: DpDw<IntrospectionType>) {
if (this.isTypeKind('OBJECT', type)) {
this.normalizeFields(type);
this.normalizeInterfaces(type);
}

if (this.isTypeKind('INTERFACE', type)) {
this.normalizeFields(type);
this.normalizeInterfaces(type);
this.normalizePossibleTypes(type);
}

if (this.isTypeKind('UNION', type)) {
this.normalizePossibleTypes(type);
}

if (this.isTypeKind('INPUT_OBJECT', type)) {
this.normalizeInputFields(type);
}
}

private normalizeDirectives(obj: {
directives?: DpDw<IntrospectionDirective>[];
}) {
obj.directives ??= [];
if (Array.isArray(obj.directives)) {
obj.directives.forEach((directive) => this.normalizeArgs(directive));
}
}
private normalizeFields(obj: { fields?: DpDw<IntrospectionField>[] }) {
obj.fields ??= [];
if (Array.isArray(obj.fields)) {
obj.fields.forEach((field) => this.normalizeArgs(field));
}
}

private normalizeInterfaces(obj: {
interfaces?: DpDw<IntrospectionInterfaceType>[];
}) {
obj.interfaces ??= [];
}

private normalizePossibleTypes(obj: {
possibleTypes?: DpDw<IntrospectionObjectType>[];
}) {
obj.possibleTypes ??= [];
}

private normalizeInputFields(obj: {
inputFields?: DpDw<IntrospectionInputValue>[];
}) {
obj.inputFields ??= [];
}

private normalizeArgs(obj: { args?: DpDw<IntrospectionInputValue>[] }) {
obj.args ??= [];
}

private isTypeKind<
T extends IntrospectionType['kind'],
U extends IntrospectionType
>(kind: T, type: DpDw<U>): type is DpDw<Extract<U, { kind: T }>> {
return typeof type === 'object' && 'kind' in type && type.kind === kind;
}
}
51 changes: 24 additions & 27 deletions packages/core/tests/GraphQLImporter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { resolve } from 'path';
import { promisify } from 'util';

describe('GraphQLImporter', () => {
const readFileAsync = promisify(readFile);

let sut!: GraphQLImporter;

beforeEach(() => {
Expand Down Expand Up @@ -32,61 +34,56 @@ describe('GraphQLImporter', () => {
expect(spec).toBeUndefined();
});

it('should import introspection envelope', async () => {
it('should import introspection', async () => {
// arrange
const expected = JSON.parse(
await promisify(readFile)(
resolve(__dirname, './fixtures/graphql-introspection.json'),
resolve(__dirname, './fixtures/graphql-introspection.result.json'),
'utf8'
)
);

const content = JSON.stringify(expected);
const input = await promisify(readFile)(
resolve(__dirname, './fixtures/graphql-introspection.json'),
'utf8'
);

// act
const spec = await sut.import(content);
const spec = await sut.import(input);

// assert
expect(spec).toMatchObject({
expect(spec).toStrictEqual({
doc: expected,
format: 'json',
type: 'graphql',
name: 'example.com-c00f7d6a02b8e2fb143fd737b7302c15'
});
});

it.each([
{
test: 'single',
input: {
url: 'https://example.com/graphql',
data: 'type Foo { bar : String!} type Query { foo: Foo }'
}
},
{
test: 'multiple',
input: {
url: 'https://example.com/graphql',
data: ['type Foo { bar : String!}', 'type Query { foo: Foo }']
}
}
])('should import $test SDL envelope', async ({ input }) => {
it('should import SDL', async () => {
// arrange

const expected = JSON.parse(
await promisify(readFile)(
resolve(__dirname, './fixtures/graphql-introspection.json'),
await readFileAsync(
resolve(__dirname, './fixtures/graphql-schema.result.json'),
'utf8'
)
);

const content = JSON.stringify(input);
const sdl = await readFileAsync(
resolve(__dirname, './fixtures/graphql-schema.graphql'),
'utf8'
);

const input = JSON.stringify({
url: 'https://example.com/graphql',
data: [sdl]
});

// act
const spec = await sut.import(content);
const spec = await sut.import(input);

// assert
expect(spec).toMatchObject({
expect(spec).toStrictEqual({
doc: expected,
format: 'json',
type: 'graphql',
Expand Down
4 changes: 2 additions & 2 deletions packages/core/tests/fixtures/graphql-introspection.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
{
"name": "bar",
"description": null,
"args": [],
"args": null,
"type": {
"kind": "NON_NULL",
"name": null,
Expand All @@ -33,7 +33,7 @@
}
],
"inputFields": null,
"interfaces": [],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
Expand Down
Loading

0 comments on commit a7a540f

Please sign in to comment.