Skip to content

Commit

Permalink
feat(core): introduce GraphQL importer
Browse files Browse the repository at this point in the history
closes #232
  • Loading branch information
ostridm committed Mar 25, 2024
1 parent ef1ebd8 commit ea56eb3
Show file tree
Hide file tree
Showing 13 changed files with 1,594 additions and 30 deletions.
270 changes: 244 additions & 26 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@
"coverage": "cross-env NODE_ENV=test jest --coverage"
},
"dependencies": {
"@graphql-tools/graphql-file-loader": "^8.0.1",
"@graphql-tools/load": "^8.0.2",
"graphql": "^16.8.1",
"js-yaml": "^4.1.0",
"openapi-types": "^10.0.0",
"tslib": "^2.3.1"
Expand Down
107 changes: 107 additions & 0 deletions packages/core/src/importers/GraphQLImporter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { BaseImporter } from './BaseImporter';
import { ImporterType } from './ImporterType';
import { isArrayOfStrings } from '../utils';
import { type DocFormat, type Spec } from './Spec';
import { GraphQL, introspectionFromSchema } from '../types';
import { loadSchema } from '@graphql-tools/load';
import { URL } from 'url';
import { type BinaryLike, createHash } from 'crypto';

export class GraphQLImporter extends BaseImporter<ImporterType.GRAPHQL> {
get type(): ImporterType.GRAPHQL {
return ImporterType.GRAPHQL;
}

constructor() {
super();
}

public async import(
content: string,
expectedFormat?: DocFormat
): Promise<Spec<ImporterType.GRAPHQL, GraphQL.Document> | undefined> {
try {
const spec = await super.import(content, expectedFormat);

return spec
? {
...spec,
doc: await this.tryConvertSDL(spec.doc)
}
: spec;
} catch {
// noop
}

return Promise.resolve(undefined);
}

public isSupported(
spec: unknown
): spec is GraphQL.Document {
return (
this.isGraphQLSDLEnvelope(spec) ||
this.isGraphQlIntrospectionEnvelope(spec)
);
}

protected fileName({
doc
}: {
doc: GraphQL.Document;
format: DocFormat;
}): string | undefined {
const url = new URL(doc.url);
const checkSum = this.generateCheckSum(url.toString());

return `${url.hostname}-${checkSum}`.toLowerCase();
}

private async tryConvertSDL(
obj: GraphQL.Document
): Promise<GraphQL.Document> {
if (this.isGraphQLSDLEnvelope(obj)) {
const schema = await loadSchema(obj.data, {
loaders: []
});

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

return obj;
}

private isGraphQLSDLEnvelope(
obj: unknown
): obj is GraphQL.GraphQLEnvelope<string | string[]> {
return (
typeof obj === 'object' &&
'url' in obj &&
typeof (obj as GraphQL.GraphQLEnvelope<string>).url === 'string' &&
'data' in obj &&
(typeof (obj as GraphQL.GraphQLEnvelope<string>).data === 'string' ||
isArrayOfStrings((obj as GraphQL.GraphQLEnvelope<string[]>).data))
);
}

private isGraphQlIntrospectionEnvelope(
obj: unknown
): obj is GraphQL.Document {
return (
typeof obj === 'object' &&
'url' in obj &&
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'
);
}

private generateCheckSum(value: BinaryLike): string {
return createHash('md5').update(value).digest('hex');
}
}
3 changes: 2 additions & 1 deletion packages/core/src/importers/ImporterType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ export enum ImporterType {
HAR = 'har',
OASV3 = 'oasv3',
OASV2 = 'oasv2',
POSTMAN = 'postman'
POSTMAN = 'postman',
GRAPHQL = 'graphql'
}
4 changes: 3 additions & 1 deletion packages/core/src/importers/Spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ImporterType } from './ImporterType';
import { Har, OpenAPIV2, OpenAPIV3, Postman } from '../types';
import { Har, OpenAPIV2, OpenAPIV3, Postman, GraphQL } from '../types';

export type DocType = `${ImporterType}` | string;

Expand All @@ -9,6 +9,8 @@ export type Doc<T extends DocType> = T extends ImporterType.OASV2
? OpenAPIV3.Document
: T extends ImporterType.POSTMAN
? Postman.Document
: T extends ImporterType.GRAPHQL
? GraphQL.Document
: T extends ImporterType.HAR
? Har
: unknown;
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/importers/SpecImporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@ import { OASV3Importer } from './OASV3Importer';
import { PostmanImporter } from './PostmanImporter';
import { OASV2Importer } from './OASV2Importer';
import { HARImporter } from './HARImporter';
import { GraphQLImporter } from './GraphQLImporter';

export class SpecImporter implements Importer<DocType> {
constructor(
private readonly importers: ReadonlyArray<Importer<DocType>> = [
new HARImporter(),
new OASV3Importer(),
new PostmanImporter(),
new OASV2Importer()
new OASV2Importer(),
new GraphQLImporter()
]
) {}

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/importers/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './GraphQLImporter';
export * from './HARImporter';
export * from './Importer';
export * from './ImporterErrorProvider';
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/types/graphql.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { IntrospectionQuery } from 'graphql';

// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace GraphQL {
export interface GraphQLEnvelope<
T extends IntrospectionQuery | string | string[]
> {
url: string;
data: T;
}

export type Document = GraphQLEnvelope<IntrospectionQuery>;
}

export * from 'graphql';
3 changes: 2 additions & 1 deletion packages/core/src/types/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './har';
export * from './openapi';
export * from './postman';
export * from './openapi';
export * from './graphql';
1 change: 1 addition & 0 deletions packages/core/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './first';
export * from './url';
export * from './is-array-of-strings'
3 changes: 3 additions & 0 deletions packages/core/src/utils/is-array-of-strings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const isArrayOfStrings = (data: unknown) => !!data && Array.isArray(data)
? data.every(item => typeof item === 'string')
: false;
97 changes: 97 additions & 0 deletions packages/core/tests/GraphQLImporter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { GraphQLImporter } from '../src/importers/GraphQLImporter';
import { readFile } from 'fs';
import { resolve } from 'path';
import { promisify } from 'util';

describe('GraphQLImporter', () => {
let sut!: GraphQLImporter;

beforeEach(() => {
sut = new GraphQLImporter();
});

describe('type', () => {
it(`should return graphql`, () => {
// act
const result = sut.type;

// assert
expect(result).toStrictEqual('graphql');
});
});

describe('import', () => {
it('should not import unparsable content ', async () => {
// arrange
const content = '{';

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

// assert
expect(spec).toBeUndefined();
});

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

const content = JSON.stringify(expected);

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

// assert
expect(spec).toMatchObject({
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 }) => {
// arrange

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

const content = JSON.stringify(input);

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

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

0 comments on commit ea56eb3

Please sign in to comment.