Skip to content

Commit

Permalink
feat(graphql): introduce GraphQL Converter
Browse files Browse the repository at this point in the history
closes #237
  • Loading branch information
ostridm committed Mar 29, 2024
1 parent f79f526 commit 0eee459
Show file tree
Hide file tree
Showing 79 changed files with 4,057 additions and 2 deletions.
47 changes: 47 additions & 0 deletions packages/graphql/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,53 @@ To install the library, run the following command:
$ npm i --save @har-sdk/graphql
```

# Usage

To convert your introspection, use the `graphql2har` function as follows:

```js
import introspection from './graphql-introspection.json' assert { type: 'json' };
import { graphql2har } from '@har-sdk/graphql';

const requests = await graphql2har({
...introspection,
url: 'https://example.com/graphql'
});

console.log(requests);
```

If you want to skip some kind of operation layouts or limit the result HAR requests quantity, you can do this by passing an options object as the second parameter:

```js
import introspection from './graphql-introspection.json' assert { type: 'json' };
import { graphql2har } from '@har-sdk/graphql';

const requests = await graphql2har(
{
...introspection,
url: 'https://example.com/graphql'
},
{
skipFileUploads: true,
limit: 10
}
);

console.log(requests);
```

Here is a table describing the options for the `graphql2har` function:

| Option | Description |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `skipInPlaceValues` | If set to `true`, the function will not produce requests for operations having data provided as argument default values. |
| `skipExternalizedVariables` | If set to `true`, the function will skip requests for operations having data injected as variables, the actual data values passed in `variables` node of operation payload. |
| `skipFileUploads` | If set to `true`, the function will not create `multipart/form-data` requests according to [GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec). |
| `includeSimilarOperations` | If set to `true`, the function will skip deduplocation of the equal operations which may occur when the operation has no argumnents. |
| `operationCostThreshold` | This property can be used to manage the statement complexity via the threshold for the operation cost. Cost is claculation is primitive - each input argument or output selection field costs 1. When the overall operation complexity reaches the threshold the operation sampling stops. |
| `limit` | This property can be used to limit the number of HAR requests. |

## License

Copyright © 2024 [Bright Security](https://brightsec.com/).
Expand Down
7 changes: 5 additions & 2 deletions packages/graphql/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,15 @@
"lint": "eslint --ignore-path ../../.eslintignore .",
"format": "prettier --ignore-path ../../.prettierignore --check .",
"compile": "tsc -b tsconfig.build.json",
"test": "cross-env NODE_ENV=test jest --passWithNoTests",
"test": "cross-env NODE_ENV=test jest",
"coverage": "cross-env NODE_ENV=test jest --coverage"
},
"dependencies": {
"@har-sdk/core": "*",
"tslib": "^2.3.1"
},
"devDependencies": {}
"devDependencies": {
"@graphql-tools/graphql-file-loader": "^8.0.1",
"@graphql-tools/load": "^8.0.2"
}
}
9 changes: 9 additions & 0 deletions packages/graphql/src/converter/Converter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { ConverterOptions } from './ConverterOptions';
import { GraphQL, Request } from '@har-sdk/core';

export interface Converter {
convert(
envelope: GraphQL.Document,
options: ConverterOptions
): Promise<Request[]>;
}
4 changes: 4 additions & 0 deletions packages/graphql/src/converter/ConverterConstants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export class ConverterConstants {
public static readonly OPERATION_COST_THRESHOLD = 100;
public static readonly MAX_OPERATIONS_OUTPUT = 10_000;
}
8 changes: 8 additions & 0 deletions packages/graphql/src/converter/ConverterOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export interface ConverterOptions {
skipInPlaceValues?: boolean;
skipExternalizedVariables?: boolean;
skipFileUploads?: boolean;
includeSimilarOperations?: boolean;
limit?: number;
operationCostThreshold?: number;
}
64 changes: 64 additions & 0 deletions packages/graphql/src/converter/DefaultConverter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Converter } from './Converter';
import { ConverterOptions } from './ConverterOptions';
import {
Operation,
Operations,
DefaultOperations,
OperationRequestBuilder
} from './operations';
import { GraphQL, Request } from '@har-sdk/core';

export class DefaultConverter implements Converter {
constructor(
private readonly operations: Operations = new DefaultOperations(),
private readonly requestBuilder: OperationRequestBuilder = new OperationRequestBuilder()
) {}

public async convert(
doc: GraphQL.Document,
options: ConverterOptions = {}
): Promise<Request[]> {
if (!this.isGraphQLDocument(doc)) {
throw new TypeError('Please provide a valid GraphQL document.');
}

const operations = this.operations.create(doc.data, options);

return operations.map((operation: Operation) =>
this.requestBuilder.build({ operation, url: doc.url })
);
}

private isGraphQLDocument(obj: object): obj is GraphQL.Document {
const hasValidUrl =
'url' in obj &&
typeof (obj as GraphQL.Document).url === 'string' &&
this.tryParseUrl((obj as GraphQL.Document).url as string);

const schema =
'data' in obj && '__schema' in (obj as GraphQL.Document).data
? (obj as GraphQL.Document).data.__schema
: undefined;

const hasRequiredProperties =
!!schema &&
typeof schema === 'object' &&
typeof schema.queryType === 'object' &&
typeof schema.queryType.name === 'string' &&
Array.isArray(schema.types);

return hasValidUrl && hasRequiredProperties;
}

private tryParseUrl(url: string): boolean {
try {
new URL(url);

return true;
} catch {
// noop
}

return false;
}
}
68 changes: 68 additions & 0 deletions packages/graphql/src/converter/GraphQlTypeRef.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import {
type IntrospectionInputType,
type IntrospectionInputTypeRef,
type IntrospectionListTypeRef,
type IntrospectionNamedTypeRef,
type IntrospectionNonNullTypeRef,
type IntrospectionOutputType,
type IntrospectionOutputTypeRef
} from '@har-sdk/core';

export class GraphQlTypeRef<
TypeRef extends IntrospectionOutputTypeRef | IntrospectionInputTypeRef,
Type extends TypeRef extends IntrospectionOutputTypeRef
? IntrospectionOutputType
: IntrospectionInputType
> {
public readonly typeRef: IntrospectionNamedTypeRef<Type>;

get type(): string {
return this.stringify(this.originalTypeRef);
}

private readonly originalTypeRef: TypeRef;

constructor(typeRef: TypeRef) {
this.originalTypeRef = typeRef;
this.typeRef = this.unwrap(typeRef);
}

private unwrap(typeRef: TypeRef): IntrospectionNamedTypeRef<Type> {
if (this.isNonNullTypeRef(typeRef)) {
return this.unwrap(typeRef.ofType);
}

if (this.isListTypeRef(typeRef)) {
return this.unwrap(typeRef.ofType);
}

return typeRef as IntrospectionNamedTypeRef<Type>;
}

private stringify(typeRef: TypeRef): string {
if (this.isNonNullTypeRef(typeRef)) {
return `${this.stringify(typeRef.ofType)}!`;
}

if (this.isListTypeRef(typeRef)) {
return `[${this.stringify(typeRef.ofType)}]`;
}

return (typeRef as IntrospectionNamedTypeRef<Type>).name;
}

private isNonNullTypeRef(
type: object
): type is IntrospectionNonNullTypeRef<TypeRef> {
return (
'kind' in type &&
(type as IntrospectionNonNullTypeRef).kind === 'NON_NULL'
);
}

private isListTypeRef(
type: object
): type is IntrospectionListTypeRef<TypeRef> {
return 'kind' in type && (type as IntrospectionListTypeRef).kind === 'LIST';
}
}
3 changes: 3 additions & 0 deletions packages/graphql/src/converter/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { ConverterOptions } from './ConverterOptions';
export { Converter } from './Converter';
export { DefaultConverter } from './DefaultConverter';
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { InputSamplers } from './InputSamplers';
import { InputSampler } from './InputSampler';
import { InputObjectSampler } from './InputObjectSampler';
import { EnumSampler } from './EnumSampler';
import { GraphQLListSampler } from './ListSampler';
import { NonNullSampler } from './NonNullSampler';
import { UploadScalarSampler } from './UploadScalarSampler';
import { type IntrospectionInputTypeRef } from '@har-sdk/core';

export class DefaultInputSamplers implements InputSamplers {
constructor(
private readonly inputSamplers: InputSampler[] = [
new GraphQLListSampler(),
new NonNullSampler(),
new InputObjectSampler(),
new EnumSampler(),
new UploadScalarSampler()
]
) {}

public find(typeRef: IntrospectionInputTypeRef): InputSampler | undefined {
return this.inputSamplers.find((s) => s.supportsType(typeRef));
}
}
35 changes: 35 additions & 0 deletions packages/graphql/src/converter/input-samplers/EnumSampler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { InputSampler, type InputSamplerOptions } from './InputSampler';
import {
type IntrospectionEnumType,
type IntrospectionEnumValue,
type IntrospectionInputTypeRef,
type IntrospectionNamedTypeRef
} from '@har-sdk/core';

export class EnumSampler implements InputSampler {
public supportsType(
typeRef: IntrospectionInputTypeRef
): typeRef is IntrospectionNamedTypeRef<IntrospectionEnumType> {
return 'kind' in typeRef && typeRef.kind === 'ENUM';
}

public sample(
typeRef: IntrospectionInputTypeRef,
{ schema }: InputSamplerOptions
): string | undefined {
if (!this.supportsType(typeRef)) {
return undefined;
}

return schema.types
.filter(
(type): type is IntrospectionEnumType => type.name === typeRef.name
)
.map((type) => {
const [value]: readonly IntrospectionEnumValue[] = type.enumValues;

return value ? value.name : 'null';
})
.join('');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { InputSampler, type InputSamplerOptions } from './InputSampler';
import { GraphQlTypeRef } from '../GraphQlTypeRef';
import {
type IntrospectionInputTypeRef,
type IntrospectionInputObjectType,
type IntrospectionInputType,
type IntrospectionNamedTypeRef,
type IntrospectionSchema
} from '@har-sdk/core';

export class InputObjectSampler implements InputSampler {
public supportsType(
typeRef: IntrospectionInputTypeRef
): typeRef is IntrospectionNamedTypeRef<IntrospectionInputObjectType> {
return 'kind' in typeRef && typeRef.kind === 'INPUT_OBJECT';
}

public sample(
typeRef: IntrospectionInputTypeRef,
options: InputSamplerOptions
): string | undefined {
if (!this.supportsType(typeRef)) {
return undefined;
}

const { schema, visitedTypes } = options;

const type = this.findType(schema, typeRef);

if (!type) {
return undefined;
}

visitedTypes.push(type.name);

try {
const sample = this.sampleFields(type, options).join(', ');

return sample ? `{${sample}}` : undefined;
} finally {
visitedTypes.pop();
}
}

private sampleFields(
type: IntrospectionInputObjectType,
options: InputSamplerOptions
) {
const { inputSamplers, visitedTypes, pointer } = options;

return type.inputFields
.map((field) => {
const resolvedTypeRef = new GraphQlTypeRef(field.type);
const { typeRef } = resolvedTypeRef;

const visited = visitedTypes.includes(typeRef.name);

if (visited) {
return undefined;
}

if (field.defaultValue !== undefined && field.defaultValue !== null) {
return field.defaultValue;
}

try {
pointer.push(field.name);

const sample = inputSamplers
.find(field.type)
?.sample(field.type, options);

return sample ? `${field.name}: ${sample}` : sample;
} finally {
pointer.pop();
}
})
.filter((field): field is string => !!field);
}

private findType(
schema: IntrospectionSchema,
typeRef: IntrospectionNamedTypeRef<IntrospectionInputType>
) {
const [introspectionInputObjectType]: IntrospectionInputObjectType[] =
schema.types.filter(
(type): type is IntrospectionInputObjectType =>
type.kind === 'INPUT_OBJECT' && type.name === typeRef.name
);

return introspectionInputObjectType;
}
}
Loading

0 comments on commit 0eee459

Please sign in to comment.