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 28, 2024
1 parent ef1ebd8 commit 4d8c69c
Show file tree
Hide file tree
Showing 15 changed files with 3,809 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
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
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;
95 changes: 95 additions & 0 deletions packages/core/tests/GraphQLImporter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
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

0 comments on commit 4d8c69c

Please sign in to comment.