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(graphql): introduce GraphQL Converter #241

Draft
wants to merge 1 commit into
base: feat_#237/introduce-graphql-package
Choose a base branch
from
Draft
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
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
Loading