Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): introduce GraphQL importer #233

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
104 changes: 104 additions & 0 deletions packages/core/src/importers/GraphQLImporter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This utility func is only used once. Do we really need it?

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;
Comment on lines +1 to +3
Copy link
Member

@derevnjuk derevnjuk Aug 21, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
export const isArrayOfStrings = (data: unknown) => !!data && Array.isArray(data)
? data.every(item => typeof item === 'string')
: false;
export const isArrayOfStrings = <T extends string>(data: unknown): data is T[] => Array.isArray(data) && data.every(item => typeof item === 'string');

94 changes: 94 additions & 0 deletions packages/core/tests/GraphQLImporter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { GraphQLImporter } from '../src/importers/GraphQLImporter';
import { readFile } from 'fs';
import { resolve } from 'path';
import { promisify } from 'util';

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

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 input = await promisify(readFile)(
resolve(__dirname, './fixtures/graphql.json'),
'utf8'
);

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

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

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

it('should import SDL envelope', async () => {
// arrange
const input = JSON.stringify({
url: 'https://example.com/graphql',
data: [
await readFileAsync(
resolve(__dirname, './fixtures/graphql.graphql'),
'utf-8'
)
]
});

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

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

// assert
expect(spec).toMatchObject({
doc: expected,
format: 'json',
type: 'graphql',
name: 'example.com-c00f7d6a02b8e2fb143fd737b7302c15'
});
});
});
});
43 changes: 43 additions & 0 deletions packages/core/tests/fixtures/graphql.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
interface Identifiable {
id: ID!
}

type Foo implements Identifiable {
id: ID!
fooField: String!
}

type Bar implements Identifiable {
id: ID!
barField: Int!
}

type Baz {
id: ID!
bazField: Float!
}

type Qux {
id: ID!
quxField: Boolean!
}

input QuxInput {
quxField: Boolean!
}

union FooQux = Foo | Qux

type Query {
getFoo(id: ID!): Foo
getBar(id: ID!): Bar
getBaz(id: ID!): Baz
getFooOrQux(id: ID!): FooQux
}

type Mutation {
createFoo(fooField: String!): Foo
updateBar(id: ID!, barField: Int!): Bar
deleteBaz(id: ID!): Baz
createQux(qux: QuxInput!): Qux
}
Loading
Loading