diff --git a/packages/graphql/README.md b/packages/graphql/README.md index 715fed7d..c70addef 100644 --- a/packages/graphql/README.md +++ b/packages/graphql/README.md @@ -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/). diff --git a/packages/graphql/package.json b/packages/graphql/package.json index 3fbec7d2..bf24036c 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -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" + } } diff --git a/packages/graphql/src/converter/Converter.ts b/packages/graphql/src/converter/Converter.ts new file mode 100644 index 00000000..27ffc69c --- /dev/null +++ b/packages/graphql/src/converter/Converter.ts @@ -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; +} diff --git a/packages/graphql/src/converter/ConverterConstants.ts b/packages/graphql/src/converter/ConverterConstants.ts new file mode 100644 index 00000000..8ad82382 --- /dev/null +++ b/packages/graphql/src/converter/ConverterConstants.ts @@ -0,0 +1,4 @@ +export class ConverterConstants { + public static readonly OPERATION_COST_THRESHOLD = 100; + public static readonly MAX_OPERATIONS_OUTPUT = 10_000; +} diff --git a/packages/graphql/src/converter/ConverterOptions.ts b/packages/graphql/src/converter/ConverterOptions.ts new file mode 100644 index 00000000..a9c1a45f --- /dev/null +++ b/packages/graphql/src/converter/ConverterOptions.ts @@ -0,0 +1,8 @@ +export interface ConverterOptions { + skipInPlaceValues?: boolean; + skipExternalizedVariables?: boolean; + skipFileUploads?: boolean; + includeSimilarOperations?: boolean; + limit?: number; + operationCostThreshold?: number; +} diff --git a/packages/graphql/src/converter/DefaultConverter.ts b/packages/graphql/src/converter/DefaultConverter.ts new file mode 100644 index 00000000..7d9c3704 --- /dev/null +++ b/packages/graphql/src/converter/DefaultConverter.ts @@ -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 { + 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; + } +} diff --git a/packages/graphql/src/converter/GraphQlTypeRef.ts b/packages/graphql/src/converter/GraphQlTypeRef.ts new file mode 100644 index 00000000..5fc5f579 --- /dev/null +++ b/packages/graphql/src/converter/GraphQlTypeRef.ts @@ -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; + + 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 { + if (this.isNonNullTypeRef(typeRef)) { + return this.unwrap(typeRef.ofType); + } + + if (this.isListTypeRef(typeRef)) { + return this.unwrap(typeRef.ofType); + } + + return typeRef as IntrospectionNamedTypeRef; + } + + 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).name; + } + + private isNonNullTypeRef( + type: object + ): type is IntrospectionNonNullTypeRef { + return ( + 'kind' in type && + (type as IntrospectionNonNullTypeRef).kind === 'NON_NULL' + ); + } + + private isListTypeRef( + type: object + ): type is IntrospectionListTypeRef { + return 'kind' in type && (type as IntrospectionListTypeRef).kind === 'LIST'; + } +} diff --git a/packages/graphql/src/converter/index.ts b/packages/graphql/src/converter/index.ts new file mode 100644 index 00000000..b01bf862 --- /dev/null +++ b/packages/graphql/src/converter/index.ts @@ -0,0 +1,3 @@ +export { ConverterOptions } from './ConverterOptions'; +export { Converter } from './Converter'; +export { DefaultConverter } from './DefaultConverter'; diff --git a/packages/graphql/src/converter/input-samplers/DefaultInputSamplers.ts b/packages/graphql/src/converter/input-samplers/DefaultInputSamplers.ts new file mode 100644 index 00000000..06f1b161 --- /dev/null +++ b/packages/graphql/src/converter/input-samplers/DefaultInputSamplers.ts @@ -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)); + } +} diff --git a/packages/graphql/src/converter/input-samplers/EnumSampler.ts b/packages/graphql/src/converter/input-samplers/EnumSampler.ts new file mode 100644 index 00000000..5fb55741 --- /dev/null +++ b/packages/graphql/src/converter/input-samplers/EnumSampler.ts @@ -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 { + 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(''); + } +} diff --git a/packages/graphql/src/converter/input-samplers/InputObjectSampler.ts b/packages/graphql/src/converter/input-samplers/InputObjectSampler.ts new file mode 100644 index 00000000..0149a23d --- /dev/null +++ b/packages/graphql/src/converter/input-samplers/InputObjectSampler.ts @@ -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 { + 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 + ) { + const [introspectionInputObjectType]: IntrospectionInputObjectType[] = + schema.types.filter( + (type): type is IntrospectionInputObjectType => + type.kind === 'INPUT_OBJECT' && type.name === typeRef.name + ); + + return introspectionInputObjectType; + } +} diff --git a/packages/graphql/src/converter/input-samplers/InputSampler.ts b/packages/graphql/src/converter/input-samplers/InputSampler.ts new file mode 100644 index 00000000..4b2f5bef --- /dev/null +++ b/packages/graphql/src/converter/input-samplers/InputSampler.ts @@ -0,0 +1,23 @@ +import { type InputSamplers } from './InputSamplers'; +import { type OperationFile } from '../operations/Operations'; +import { + type IntrospectionInputTypeRef, + type IntrospectionSchema +} from '@har-sdk/core'; + +export interface InputSamplerOptions { + readonly inputSamplers: InputSamplers; + readonly schema: IntrospectionSchema; + readonly visitedTypes: string[]; + readonly pointer: string[]; + readonly files: OperationFile[]; +} + +export interface InputSampler { + supportsType(typeRef: IntrospectionInputTypeRef): boolean; + + sample( + typeRef: IntrospectionInputTypeRef, + options: InputSamplerOptions + ): string | undefined; +} diff --git a/packages/graphql/src/converter/input-samplers/InputSamplers.ts b/packages/graphql/src/converter/input-samplers/InputSamplers.ts new file mode 100644 index 00000000..f1d9397e --- /dev/null +++ b/packages/graphql/src/converter/input-samplers/InputSamplers.ts @@ -0,0 +1,6 @@ +import { type InputSampler } from './InputSampler'; +import { type IntrospectionInputTypeRef } from '@har-sdk/core'; + +export interface InputSamplers { + find(typeRef: IntrospectionInputTypeRef): InputSampler | undefined; +} diff --git a/packages/graphql/src/converter/input-samplers/ListSampler.ts b/packages/graphql/src/converter/input-samplers/ListSampler.ts new file mode 100644 index 00000000..6f1a561e --- /dev/null +++ b/packages/graphql/src/converter/input-samplers/ListSampler.ts @@ -0,0 +1,35 @@ +import { InputSampler, type InputSamplerOptions } from './InputSampler'; +import { + type IntrospectionInputTypeRef, + type IntrospectionListTypeRef +} from '@har-sdk/core'; + +export class GraphQLListSampler implements InputSampler { + public supportsType( + typeRef: IntrospectionInputTypeRef + ): typeRef is IntrospectionListTypeRef { + return 'kind' in typeRef && typeRef.kind === 'LIST'; + } + + public sample( + typeRef: IntrospectionInputTypeRef, + options: InputSamplerOptions + ): string | undefined { + if (!this.supportsType(typeRef)) { + return undefined; + } + + const { pointer, inputSamplers } = options; + + try { + pointer.push('0'); + const sample = inputSamplers + .find(typeRef.ofType) + ?.sample(typeRef.ofType, options); + + return sample !== undefined ? `[${sample}]` : sample; + } finally { + pointer.pop(); + } + } +} diff --git a/packages/graphql/src/converter/input-samplers/NonNullSampler.ts b/packages/graphql/src/converter/input-samplers/NonNullSampler.ts new file mode 100644 index 00000000..1d63d8dc --- /dev/null +++ b/packages/graphql/src/converter/input-samplers/NonNullSampler.ts @@ -0,0 +1,32 @@ +import { InputSampler, type InputSamplerOptions } from './InputSampler'; +import { + type IntrospectionInputTypeRef, + type IntrospectionListTypeRef, + type IntrospectionNamedTypeRef, + type IntrospectionInputType, + type IntrospectionNonNullTypeRef +} from '@har-sdk/core'; + +export class NonNullSampler implements InputSampler { + public supportsType( + typeRef: IntrospectionInputTypeRef + ): typeRef is IntrospectionNonNullTypeRef< + | IntrospectionNamedTypeRef + | IntrospectionListTypeRef + > { + return 'kind' in typeRef && typeRef.kind === 'NON_NULL'; + } + + public sample( + typeRef: IntrospectionInputTypeRef, + options: InputSamplerOptions + ): string | undefined { + if (!this.supportsType(typeRef)) { + return undefined; + } + + const { inputSamplers } = options; + + return inputSamplers.find(typeRef.ofType)?.sample(typeRef.ofType, options); + } +} diff --git a/packages/graphql/src/converter/input-samplers/ScalarSampler.ts b/packages/graphql/src/converter/input-samplers/ScalarSampler.ts new file mode 100644 index 00000000..74e15018 --- /dev/null +++ b/packages/graphql/src/converter/input-samplers/ScalarSampler.ts @@ -0,0 +1,148 @@ +import { type InputSampler, type InputSamplerOptions } from './InputSampler'; +import { + type IntrospectionInputTypeRef, + type IntrospectionNamedTypeRef, + type IntrospectionScalarType +} from '@har-sdk/core'; + +export class ScalarSampler implements InputSampler { + private readonly SAMPLES: ReadonlyMap = new Map< + string, + string + >([ + ['String', '"lorem"'], + ['Int', '42'], + ['Boolean', 'true'], + ['Float', '123.45'], + ['ID', '"f323fed3-ae3e-41df-abe2-540859417876"'], + + // Scalar type names of Node.js platform, borrowed from 'graphql-scalars' project: + // https://github.com/Urigo/graphql-scalars/tree/master/src/scalars + + ['Date', '"2023-12-17"'], + ['Time', '"09:09:06.13Z"'], + ['DateTime', '"2023-02-01T00:00:00.000Z"'], + ['DateTimeISO', '"2023-02-01T00:00:00.000Z"'], + ['Timestamp', '1705494979'], + ['TimeZone', '"America/Chicago"'], + ['UtcOffset', '"+05:00"'], + ['Duration', '"PT65H40M22S"'], + ['ISO8601Duration', '"P1D"'], + ['LocalDate', '"2023-01-01"'], + ['LocalTime', '"23:59:59.999"'], + ['LocalDateTime', '"2023-01-01T12:00:00+01:00"'], + ['LocalEndTime', '"23:59:59.999"'], + ['EmailAddress', '"root@example.com"'], + ['NegativeFloat', '-123.45'], + ['NegativeInt', '-42'], + ['NonEmptyString', 'lorem'], + ['NonNegativeFloat', '0.0'], + ['NonNegativeInt', '0'], + ['NonPositiveFloat', '0.0'], + ['NonPositiveInt', '0'], + ['PhoneNumber', '"+16075551234"'], + ['PositiveFloat', '123.45'], + ['PositiveInt', '42'], + ['PostalCode', '"60031"'], + ['UnsignedFloat', '123.45'], + ['UnsignedInt', '42'], + ['URL', '"https://example.com"'], + ['BigInt', '9007199254740992'], + ['Long', '42'], + ['Byte', '"ff"'], + ['UUID', '"6d400c98-207b-416b-885b-1b1812c25d18"'], + ['GUID', '"e982c909-c347-4fbc-8ebc-e42ae5cb3312"'], + ['Hexadecimal', '"123456789ABCDEF"'], + ['HexColorCode', '"#ffcc00"'], + ['HSL', '"hsl(270, 60%, 70%)"'], + ['HSLA', '"hsla(240, 100%, 50%, .05)"'], + ['IP', '"127.0.0.1"'], + ['IPv4', '"127.0.0.1"'], + ['IPv6', '"1080:0:0:0:8:800:200C:417A"'], + ['ISBN', '"ISBN 978-0615-856"'], + [ + 'JWT', + '"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaWF0IjoxNTE2MjM5MDIyLCJwcm9qZWN0IjoiZ3JhcGhxbC1zY2FsYXJzIn0.nYdrSfE2nNRAgpiEU1uKgn2AYYKLo28Z0nhPXvsuIww"' + ], + ['Latitude', '41.89193'], + ['Longitude', '-74.00597'], + ['MAC', '"01:23:45:67:89:ab"'], + ['Port', '1337'], + ['RGB', '"rgb(255, 0, 153)"'], + ['RGBA', '"rgba(51, 170, 51, .7)"'], + ['SafeInt', '42'], + ['USCurrency', '"$22,900.00"'], + ['Currency', '"USD"'], + ['JSON', '{foo:{bar:"baz"}}'], + ['JSONObject', '{foo:{bar:"baz"}}'], + ['IBAN', '"GE29NB0000000101904917"'], + ['ObjectID', '"5e5677d71bdc2ae76344968c"'], + ['DID', '"did:example:123456789abcdefghi"'], + ['CountryCode', '"US"'], + ['Locale', '"en-US"'], + ['RoutingNumber', '"111000025"'], + ['AccountNumber', '"1234567890ABCDEF1"'], + ['Cuid', '"cjld2cyuq0000t3rmniod1foy"'], + ['SemVer', '"1.2.3"'], + ['DeweyDecimal', '1234.56'], + ['LCCSubclass', '"KBM"'], + ['IPCPatent', '"F16K 27/02"'], + ['Regex', '"^(.*)$"'], + + // Scalar type names of .netcore platform, borrowed from 'graphql-dotnet' and 'ChilliCream' projects: + // https://github.com/graphql-dotnet/graphql-dotnet/blob/master/src/GraphQL/Types/Collections/SchemaTypes.cs + // https://github.com/ChilliCream/graphql-platform/tree/main/src/HotChocolate/Core/src/Types.Scalars + + ['SByte', '"ef"'], + ['SignedByte', '"ef"'], + ['Char', '"a"'], + ['Single', '123.45'], + ['Half', '123.45'], + ['Double', '123.45'], + ['Decimal', '123.45'], + ['BigDecimal', '123.45'], + ['Int16', '42'], + ['Short', '42'], + ['UInt16', '42'], + ['UnsignedShort', '42'], + ['Int32', '42'], + ['UInt32', '42'], + ['UnsignedInt', '42'], + ['Int64', '42'], + ['Long', '42'], + ['UInt64', '42'], + ['UnsignedLong', '42'], + ['TimeSpan', '"00:00:10"'], + ['DateTimeOffset', '"2009-06-15T13:45:30.000Z"'], + ['Uri', '"https://example.com/api/v1"'] + ]); + + public supportsType( + typeRef: IntrospectionInputTypeRef + ): typeRef is IntrospectionNamedTypeRef { + return 'kind' in typeRef && typeRef.kind === 'SCALAR'; + } + + public sample( + typeRef: IntrospectionInputTypeRef, + _options: InputSamplerOptions + ): string | undefined { + if (!this.supportsType(typeRef)) { + return undefined; + } + + return ( + this.SAMPLES.get(typeRef.name) ?? + this.findSimilarTypeValue(typeRef.name) ?? + 'null' + ); + } + + private findSimilarTypeValue(typeName: string): string | undefined { + const [typeValue]: string[] = [...this.SAMPLES.entries()] + .filter(([name, _]: [string, string]) => typeName.endsWith(name)) + .map(([_, value]: [string, string]) => value); + + return typeValue; + } +} diff --git a/packages/graphql/src/converter/input-samplers/UploadScalarSampler.ts b/packages/graphql/src/converter/input-samplers/UploadScalarSampler.ts new file mode 100644 index 00000000..6af47cc7 --- /dev/null +++ b/packages/graphql/src/converter/input-samplers/UploadScalarSampler.ts @@ -0,0 +1,109 @@ +import { InputSampler, type InputSamplerOptions } from './InputSampler'; +import { ScalarSampler } from './ScalarSampler'; +import { + type IntrospectionInputTypeRef, + type IntrospectionNamedTypeRef, + type IntrospectionScalarType +} from '@har-sdk/core'; + +interface UploadSample { + name: string; + extension: string; + contentType: string; + content: string; +} + +export class UploadScalarSampler implements InputSampler { + // Upload scalar type names borrowed from GraphQL multipart request specification: + // https://github.com/jaydenseric/graphql-multipart-request-spec + // File from 'graphql-yoga' project: + // https://the-guild.dev/graphql/yoga-server/docs/features/file-uploads + private readonly UPLOAD_SCALAR_NAMES: readonly string[] = ['Upload', 'File']; + + private readonly IMAGE_FILE = { + name: 'image', + extension: '.png', + content: '\x89\x50\x4e\x47\x0d\x0A\x1a\x0a', + contentType: 'image/png' + } as const; + + private readonly BLOB_FILE = { + name: 'blob', + extension: '.bin', + content: '\x01\x02\x03\x04\x05', + contentType: 'application/octet-stream' + } as const; + + private readonly IMAGE_KEYWORDS: readonly string[] = [ + 'avatar', + 'profile', + 'img', + 'image', + 'pic', + 'bio', + 'photo', + 'shot', + 'gallery', + 'art', + 'pano', + 'paint', + 'draw', + 'view', + 'banner' + ]; + + constructor( + private readonly scalarSampler: ScalarSampler = new ScalarSampler() + ) {} + + public supportsType( + typeRef: IntrospectionInputTypeRef + ): typeRef is IntrospectionNamedTypeRef { + return this.scalarSampler.supportsType(typeRef); + } + + public sample( + typeRef: IntrospectionInputTypeRef, + options: InputSamplerOptions + ): string | undefined { + if ( + !this.supportsType(typeRef) || + !this.UPLOAD_SCALAR_NAMES.includes(typeRef.name) + ) { + return this.scalarSampler.sample(typeRef, options); + } + + const { pointer, files } = options; + + const file = this.findSample([...pointer]); + + files.push({ + pointer: pointer.join('.'), + fileName: `${file.name}${files.length > 0 ? files.length : ''}${ + file.extension + }`, + contentType: file.contentType, + content: file.content + }); + + return 'null'; + } + + private findSample(names: string[]): UploadSample { + const name = names.pop(); + + if (name === undefined) { + return this.BLOB_FILE; + } + + if ( + this.IMAGE_KEYWORDS.some((keyword) => + name.toLowerCase().includes(keyword) + ) + ) { + return this.IMAGE_FILE; + } + + return this.findSample(names); + } +} diff --git a/packages/graphql/src/converter/input-samplers/index.ts b/packages/graphql/src/converter/input-samplers/index.ts new file mode 100644 index 00000000..46b07a41 --- /dev/null +++ b/packages/graphql/src/converter/input-samplers/index.ts @@ -0,0 +1,3 @@ +export * from './DefaultInputSamplers'; +export * from './InputSampler'; +export * from './InputSamplers'; diff --git a/packages/graphql/src/converter/operations/BaseOperationBuilder.ts b/packages/graphql/src/converter/operations/BaseOperationBuilder.ts new file mode 100644 index 00000000..64b2143b --- /dev/null +++ b/packages/graphql/src/converter/operations/BaseOperationBuilder.ts @@ -0,0 +1,68 @@ +import { type OutputSelectorTreeNode } from '../output-selectors'; +import { + type OperationBuilder, + type OperationBuilderOptions +} from './OperationBuilder'; +import { type Operation } from './Operations'; +import { + type InputSamplers, + type InputSamplerOptions +} from '../input-samplers'; +import { + type IntrospectionInputValue, + type IntrospectionSchema +} from '@har-sdk/core'; + +export abstract class BaseOperationBuilder implements OperationBuilder { + protected constructor( + protected readonly schema: IntrospectionSchema, + protected readonly inputSamplers: InputSamplers + ) {} + + protected abstract buildOperation( + body: string, + options: OperationBuilderOptions + ): Operation | undefined; + + protected abstract transform( + args: readonly IntrospectionInputValue[] + ): string; + + public build(options: OperationBuilderOptions): Operation | undefined { + const { selectorRoot } = options; + const body = `{ ${this.merge(selectorRoot)} }`; + + return this.buildOperation(body, options); + } + + protected merge(node: OutputSelectorTreeNode): string { + const args = this.transform(node.value.args); + + if (node.value.primitive) { + return `${node.value.name}${args}`; + } + + const childSelections = node.children + .map((child) => this.merge(child)) + .filter((fields) => !!fields) + .join(' '); + + return childSelections + ? `${node.value.name}${args} { ${childSelections} }` + : ''; + } + + protected createSample( + arg: IntrospectionInputValue, + options: InputSamplerOptions + ) { + if (arg.defaultValue !== undefined && arg.defaultValue !== null) { + return arg.defaultValue; + } + + return this.inputSamplers.find(arg.type)?.sample(arg.type, { + ...options, + pointer: [...options.pointer, arg.name] + }); + } +} diff --git a/packages/graphql/src/converter/operations/DefaultOperations.ts b/packages/graphql/src/converter/operations/DefaultOperations.ts new file mode 100644 index 00000000..9848be43 --- /dev/null +++ b/packages/graphql/src/converter/operations/DefaultOperations.ts @@ -0,0 +1,192 @@ +import { ConverterConstants } from '../ConverterConstants'; +import { ConverterOptions } from '../ConverterOptions'; +import { type Operation } from './Operations'; +import { + OutputSelectors, + type OutputSelectorTreeNode +} from '../output-selectors'; +import { + OperationBuilderFactory, + OperationComposition +} from './OperationBuilderFactory'; +import { + type IntrospectionField, + type IntrospectionObjectType, + IntrospectionQuery, + type IntrospectionSchema, + type IntrospectionType +} from '@har-sdk/core'; + +interface GraphQLComposedOperationData { + composition: OperationComposition; + selectorRoot: OutputSelectorTreeNode; + operation?: Operation; +} + +export class DefaultOperations { + constructor( + private readonly outputSelectors: OutputSelectors = new OutputSelectors(), + private readonly operationBuilderFactory: OperationBuilderFactory = new OperationBuilderFactory() + ) {} + + public create( + introspection: IntrospectionQuery, + options: ConverterOptions = {} + ): Operation[] { + const { __schema: schema } = introspection; + + const { + limit = ConverterConstants.MAX_OPERATIONS_OUTPUT, + operationCostThreshold = ConverterConstants.OPERATION_COST_THRESHOLD, + includeSimilarOperations + } = options; + + let count = 0; + + return this.findOperations(schema) + .flatMap((operation) => + [...operation.fields].flatMap((field: IntrospectionField) => { + let ops = + count > limit + ? [] + : this.buildOperations( + { + schema, + field, + operationType: operation.name.toLowerCase() + }, + { + ...options, + operationCostThreshold + } + ); + + ops = includeSimilarOperations + ? ops + : this.omitSimilarOperations(ops); + + count += ops.length; + + return ops; + }) + ) + .map((op) => op.operation) + .slice(0, limit); + } + + private buildOperations( + { + schema, + operationType, + field + }: { + schema: IntrospectionSchema; + operationType: string; + field: IntrospectionField; + }, + options: ConverterOptions + ): Required[] { + const { skipInPlaceValues, skipExternalizedVariables, skipFileUploads } = + options; + + const selectorRoots = this.outputSelectors.create( + { schema, field }, + options + ); + + const compositions = [ + ...(skipInPlaceValues ? [] : [OperationComposition.IN_PLACE_ARGUMENTS]), + ...(skipExternalizedVariables && skipFileUploads + ? [] + : [OperationComposition.EXTERNALIZED_VARIABLES]) + ]; + + return compositions.flatMap((composition) => + selectorRoots + .map((selectorRoot) => ({ + composition, + selectorRoot, + operation: this.operationBuilderFactory + .create(composition, schema) + .build({ + selectorRoot, + operationType, + operationName: field.name + }) + })) + .filter( + ( + op: GraphQLComposedOperationData + ): op is Required => !!op.operation + ) + .filter((op) => this.shouldIncludeOperation(op, options)) + ); + } + + private shouldIncludeOperation( + op: GraphQLComposedOperationData, + options: ConverterOptions + ): boolean { + const { skipExternalizedVariables, skipFileUploads, skipInPlaceValues } = + options; + const isUpload = !!op.operation?.files?.length; + const isInPlaceComposition = + op.composition === OperationComposition.IN_PLACE_ARGUMENTS; + const isExternalizedComposition = + op.composition === OperationComposition.EXTERNALIZED_VARIABLES; + + const includeInPlaceValues = + !isUpload && !skipInPlaceValues && isInPlaceComposition; + const includeExternalizedVariables = + !isUpload && !skipExternalizedVariables && isExternalizedComposition; + const includeFileUploads = + isUpload && !skipFileUploads && isExternalizedComposition; + + return ( + includeInPlaceValues || includeExternalizedVariables || includeFileUploads + ); + } + + private omitSimilarOperations( + operations: Required[] + ) { + const inPlaceArguments = operations.filter( + (op) => op.composition === OperationComposition.IN_PLACE_ARGUMENTS + ); + + const removables = operations.filter( + (op) => + op.composition === OperationComposition.EXTERNALIZED_VARIABLES && + !op.operation.variables && + inPlaceArguments.some( + (inPlace) => inPlace.selectorRoot === op.selectorRoot + ) + ); + + return operations.filter((op) => !removables.includes(op)); + } + + private findOperations( + schema: IntrospectionSchema + ): IntrospectionObjectType[] { + const operationNames = this.getOperationNames(schema); + + return schema.types + .filter( + (x: IntrospectionType): x is IntrospectionObjectType => + x.kind === 'OBJECT' + ) + .filter((x) => x.name && operationNames.includes(x.name)); + } + + private getOperationNames(schema: IntrospectionSchema) { + const { mutationType, queryType, subscriptionType } = schema; + const operationNames: string[] = [ + queryType.name, + ...(mutationType?.name ? [mutationType.name] : []), + ...(subscriptionType?.name ? [subscriptionType.name] : []) + ]; + + return operationNames; + } +} diff --git a/packages/graphql/src/converter/operations/ExternalizedVariablesOperationBuilder.ts b/packages/graphql/src/converter/operations/ExternalizedVariablesOperationBuilder.ts new file mode 100644 index 00000000..0ee99ea0 --- /dev/null +++ b/packages/graphql/src/converter/operations/ExternalizedVariablesOperationBuilder.ts @@ -0,0 +1,130 @@ +import { type OperationBuilderOptions } from './OperationBuilder'; +import { GraphQlTypeRef } from '../GraphQlTypeRef'; +import { graphQLParseValue } from '../../utils'; +import { BaseOperationBuilder } from './BaseOperationBuilder'; +import { type Operation } from './Operations'; +import { + type InputSamplerOptions, + type InputSamplers +} from '../input-samplers'; +import { + type IntrospectionInputValue, + type IntrospectionSchema +} from '@har-sdk/core'; + +export class ExternalizedVariablesOperationBuilder extends BaseOperationBuilder { + private variables = new Map(); + + constructor(schema: IntrospectionSchema, inputSamplers: InputSamplers) { + super(schema, inputSamplers); + } + + protected buildOperation( + body: string, + { operationType, operationName }: OperationBuilderOptions + ): Operation | undefined { + const inputArgs = this.buildInputArgs(); + + const wrappingOperationName = + this.createWrappingOperationName(operationName); + + const samplerOptions: InputSamplerOptions = { + schema: this.schema, + inputSamplers: this.inputSamplers, + visitedTypes: [], + pointer: ['variables'], + files: [] + }; + + const variables = this.buildVariables(samplerOptions); + + return { + operationName: wrappingOperationName, + query: `${operationType} ${wrappingOperationName}${inputArgs} ${body}`, + ...(variables ? { variables } : {}), + ...(samplerOptions.files.length > 0 + ? { files: samplerOptions.files } + : {}) + }; + } + + protected transform(args: readonly IntrospectionInputValue[]): string { + const argList = args.map( + (arg) => `${arg.name}: $${this.getOrCreateVariable(arg)}` + ); + + return argList.length ? `(${argList})` : ''; + } + + private createWrappingOperationName(operationName: string) { + if (operationName.length > 0) { + const first = operationName.charAt(0); + + return `${ + first === first.toUpperCase() + ? first.toLowerCase() + : first.toUpperCase() + }${operationName.substring(1)}`; + } + + return operationName; + } + + private buildVariables(options: InputSamplerOptions) { + return this.variables.size === 0 + ? undefined + : Object.fromEntries( + [...this.variables.entries()].map( + ([varName, arg]: [string, IntrospectionInputValue]) => [ + varName, + this.trySampleValue(arg, options) + ] + ) + ); + } + + private buildInputArgs() { + const input = [...this.variables.entries()] + .map( + ([varName, arg]: [string, IntrospectionInputValue]) => + `$${varName}: ${new GraphQlTypeRef(arg.type).type}` + ) + .join(', '); + + return input ? `(${input})` : input; + } + + private getOrCreateVariable( + arg: IntrospectionInputValue, + index: number = 0 + ): string { + const varName = index === 0 ? arg.name : `${arg.name}${index}`; + + if (this.variables.has(varName)) { + return this.getOrCreateVariable(arg, 1 + index); + } + + this.variables.set(varName, arg); + + return varName; + } + + private trySampleValue( + arg: IntrospectionInputValue, + options: InputSamplerOptions + ): unknown { + const value = this.createSample(arg, options); + + if (value === undefined) { + return undefined; + } + + try { + return graphQLParseValue(value); + } catch { + // noop + } + + return undefined; + } +} diff --git a/packages/graphql/src/converter/operations/InPlaceArgumentsOperationBuilder.ts b/packages/graphql/src/converter/operations/InPlaceArgumentsOperationBuilder.ts new file mode 100644 index 00000000..b7955f32 --- /dev/null +++ b/packages/graphql/src/converter/operations/InPlaceArgumentsOperationBuilder.ts @@ -0,0 +1,50 @@ +import { BaseOperationBuilder } from './BaseOperationBuilder'; +import { type InputSamplers, InputSamplerOptions } from '../input-samplers'; +import { type OperationBuilderOptions } from './OperationBuilder'; +import { type Operation } from './Operations'; +import { + type IntrospectionInputValue, + type IntrospectionSchema +} from '@har-sdk/core'; + +export class InPlaceArgumentsOperationBuilder extends BaseOperationBuilder { + private isFileUploadOperation = false; + + constructor(schema: IntrospectionSchema, inputSamplers: InputSamplers) { + super(schema, inputSamplers); + } + + protected buildOperation( + body: string, + { operationType }: OperationBuilderOptions + ): Operation | undefined { + return this.isFileUploadOperation + ? undefined + : { + query: `${operationType} ${body}` + }; + } + + protected transform(args: readonly IntrospectionInputValue[]): string { + const options: InputSamplerOptions = { + schema: this.schema, + inputSamplers: this.inputSamplers, + visitedTypes: [], + files: [], + pointer: [] + }; + + const argList = args + .map((arg) => { + const sample = this.createSample(arg, options); + + this.isFileUploadOperation = + this.isFileUploadOperation || options.files.length > 0; + + return sample ? `${arg.name}: ${sample}` : sample; + }) + .filter((field): field is string => !!field); + + return argList.length ? `(${argList})` : ''; + } +} diff --git a/packages/graphql/src/converter/operations/OperationBuilder.ts b/packages/graphql/src/converter/operations/OperationBuilder.ts new file mode 100644 index 00000000..f745d0ad --- /dev/null +++ b/packages/graphql/src/converter/operations/OperationBuilder.ts @@ -0,0 +1,12 @@ +import { type OutputSelectorTreeNode } from '../output-selectors'; +import { type Operation } from './Operations'; + +export interface OperationBuilderOptions { + readonly selectorRoot: OutputSelectorTreeNode; + readonly operationName: string; + readonly operationType: string; +} + +export interface OperationBuilder { + build(options: OperationBuilderOptions): Operation | undefined; +} diff --git a/packages/graphql/src/converter/operations/OperationBuilderFactory.ts b/packages/graphql/src/converter/operations/OperationBuilderFactory.ts new file mode 100644 index 00000000..86472834 --- /dev/null +++ b/packages/graphql/src/converter/operations/OperationBuilderFactory.ts @@ -0,0 +1,33 @@ +import { type OperationBuilder } from './OperationBuilder'; +import { InputSamplers, DefaultInputSamplers } from '../input-samplers'; +import { InPlaceArgumentsOperationBuilder } from './InPlaceArgumentsOperationBuilder'; +import { ExternalizedVariablesOperationBuilder } from './ExternalizedVariablesOperationBuilder'; +import { type IntrospectionSchema } from '@har-sdk/core'; + +export enum OperationComposition { + IN_PLACE_ARGUMENTS = 1, + EXTERNALIZED_VARIABLES = 2 +} + +export class OperationBuilderFactory { + constructor( + private readonly inputSamplers: InputSamplers = new DefaultInputSamplers() + ) {} + + public create( + composition: OperationComposition, + schema: IntrospectionSchema + ): OperationBuilder { + switch (composition) { + case OperationComposition.IN_PLACE_ARGUMENTS: + return new InPlaceArgumentsOperationBuilder(schema, this.inputSamplers); + case OperationComposition.EXTERNALIZED_VARIABLES: + return new ExternalizedVariablesOperationBuilder( + schema, + this.inputSamplers + ); + default: + throw new Error(`Wrong GraphQL operation composition: ${composition}.`); + } + } +} diff --git a/packages/graphql/src/converter/operations/OperationCostAnalyzer.ts b/packages/graphql/src/converter/operations/OperationCostAnalyzer.ts new file mode 100644 index 00000000..73622a33 --- /dev/null +++ b/packages/graphql/src/converter/operations/OperationCostAnalyzer.ts @@ -0,0 +1,47 @@ +import { ConverterConstants } from '../ConverterConstants'; +import { type OutputSelectorTreeNode } from '../output-selectors'; +import { ConverterOptions } from '../ConverterOptions'; + +export class OperationCostAnalyzer { + private readonly maxCostThreshold: number; + + private cost: number = 0; + + get done() { + return this.cost >= this.maxCostThreshold; + } + + constructor(options: ConverterOptions) { + this.maxCostThreshold = + options.operationCostThreshold ?? + ConverterConstants.OPERATION_COST_THRESHOLD; + } + + public estimate(node: OutputSelectorTreeNode): OutputSelectorTreeNode { + node.value.estimatedCost = + node.value.args.length + (node.value.primitive ? 1 : 0); + + return node; + } + + public analyze(node: OutputSelectorTreeNode) { + if (node.value.primitive) { + this.cost += this.sumEstimatedCostOnce(node); + } + + return !this.done; + } + + private sumEstimatedCostOnce(node: OutputSelectorTreeNode) { + let cost = node.value.estimatedCost ?? 0; + + // ADHOC: prevent adding the same cost on next iteration + node.value.estimatedCost = undefined; + + if (node.parent) { + cost += this.sumEstimatedCostOnce(node.parent); + } + + return cost; + } +} diff --git a/packages/graphql/src/converter/operations/OperationRequestBuilder.ts b/packages/graphql/src/converter/operations/OperationRequestBuilder.ts new file mode 100644 index 00000000..fa6d76f5 --- /dev/null +++ b/packages/graphql/src/converter/operations/OperationRequestBuilder.ts @@ -0,0 +1,115 @@ +import { type Operation } from './Operations'; +import { type PostData, type Request } from '@har-sdk/core'; +// eslint-disable-next-line @typescript-eslint/naming-convention +import FormData from 'form-data'; + +export class OperationRequestBuilder { + private readonly APPLICATION_JSON = 'application/json'; + private readonly MULTIPART_FORM_DATA = 'multipart/form-data'; + private readonly BOUNDARY = '956888039105887155673143'; + private readonly CONTENT_TYPE = 'content-type'; + + public build({ + url, + operation + }: { + url: string; + operation: Operation; + }): Request { + return operation.files?.length + ? this.buildMultipart({ url, operation }) + : this.buildJson({ url, operation }); + } + + public buildJson({ + url, + operation + }: { + url: string; + operation: Operation; + }): Request { + return { + url, + method: 'POST', + httpVersion: 'HTTP/1.1', + cookies: [], + headers: [ + { + name: this.CONTENT_TYPE, + value: this.APPLICATION_JSON + } + ], + queryString: [], + headersSize: -1, + bodySize: -1, + postData: { + mimeType: this.APPLICATION_JSON, + text: this.stringify(operation) + } + }; + } + + private buildMultipart({ + url, + operation + }: { + url: string; + operation: Operation; + }): Request { + const { files } = operation; + const body = new FormData({}); + body.setBoundary(this.BOUNDARY); + + body.append('operations', this.stringify(operation)); + + const map = Object.fromEntries( + files.map((file, index) => [index, [file.pointer]]) + ); + + body.append('map', JSON.stringify(map)); + + files.forEach((file, index) => { + body.append(index.toString(), file.content, { + contentType: file.contentType, + filename: file.fileName + }); + }); + + const mimeType = `${ + this.MULTIPART_FORM_DATA + }; boundary=${body.getBoundary()}`; + + const postData: PostData & { _base64EncodedText: string } = { + mimeType, + text: body.getBuffer().toString(), + _base64EncodedText: body.getBuffer().toString('base64') + }; + + return { + url, + postData, + method: 'POST', + httpVersion: 'HTTP/1.1', + cookies: [], + headers: [ + { + name: 'content-type', + value: mimeType + } + ], + queryString: [], + headersSize: -1, + bodySize: -1 + }; + } + + private stringify({ operationName, query, variables }: Operation): string { + const operation = { + ...(operationName ? { operationName } : {}), + ...(query ? { query } : {}), + ...(variables ? { variables } : {}) + }; + + return JSON.stringify(operation); + } +} diff --git a/packages/graphql/src/converter/operations/Operations.ts b/packages/graphql/src/converter/operations/Operations.ts new file mode 100644 index 00000000..c2fd5d15 --- /dev/null +++ b/packages/graphql/src/converter/operations/Operations.ts @@ -0,0 +1,23 @@ +import { ConverterOptions } from '../ConverterOptions'; +import { type IntrospectionQuery } from '@har-sdk/core'; + +export interface OperationFile { + readonly pointer: string; + readonly content: string; + readonly contentType: string; + readonly fileName: string; +} + +export interface Operation { + readonly query: string; + readonly variables?: object; + readonly operationName?: string; + readonly files?: OperationFile[]; +} + +export interface Operations { + create( + introspection: IntrospectionQuery, + options?: ConverterOptions + ): Operation[]; +} diff --git a/packages/graphql/src/converter/operations/index.ts b/packages/graphql/src/converter/operations/index.ts new file mode 100644 index 00000000..4aa42fe1 --- /dev/null +++ b/packages/graphql/src/converter/operations/index.ts @@ -0,0 +1,3 @@ +export * from './DefaultOperations'; +export * from './Operations'; +export * from './OperationRequestBuilder'; diff --git a/packages/graphql/src/converter/output-selectors/AbstractSelector.ts b/packages/graphql/src/converter/output-selectors/AbstractSelector.ts new file mode 100644 index 00000000..8df5def5 --- /dev/null +++ b/packages/graphql/src/converter/output-selectors/AbstractSelector.ts @@ -0,0 +1,147 @@ +import { + type OutputSelector, + type OutputSelectorData, + type OutputSelectorOptions +} from './OutputSelector'; +import { GraphQlTypeRef } from '../GraphQlTypeRef'; +import { isGraphQLPrimitive } from '../../utils'; +import { + type IntrospectionField, + type IntrospectionInterfaceType, + type IntrospectionNamedTypeRef, + type IntrospectionObjectType, + type IntrospectionOutputType, + type IntrospectionOutputTypeRef, + type IntrospectionSchema +} from '@har-sdk/core'; + +export abstract class AbstractSelector + implements OutputSelector +{ + protected constructor(private readonly kind: string) {} + + protected abstract buildFields( + type: T, + options: OutputSelectorOptions + ): OutputSelectorData[]; + + public supportsType( + typeRef: IntrospectionOutputTypeRef + ): typeRef is IntrospectionNamedTypeRef { + return 'kind' in typeRef && typeRef.kind === this.kind; + } + + public select( + typeRef: IntrospectionOutputTypeRef, + options: OutputSelectorOptions + ): OutputSelectorData[] { + if (!this.supportsType(typeRef)) { + return []; + } + + const { schema } = options; + + const findType = this.findType(schema, typeRef); + + if (!findType) { + return []; + } + + return this.trackVisitedTypes(findType, options); + } + + protected selectOwnFields( + type: IntrospectionObjectType | IntrospectionInterfaceType, + options: OutputSelectorOptions + ): OutputSelectorData[] { + const { visitedTypes, excludeFields, addTypeName } = options; + + const nodes = type.fields + .map((field: IntrospectionField): OutputSelectorData | undefined => { + const { typeRef } = new GraphQlTypeRef(field.type); + + const visited = visitedTypes.includes(typeRef.name); + + const excluded = !!excludeFields?.includes(field.name); + + if (excluded || visited) { + return undefined; + } + + return { + typeRef, + args: field.args, + primitive: isGraphQLPrimitive(typeRef), + name: `${field.name}`, + options: { + ...options, + addTypeName: false, + excludeFields: undefined + } + }; + }) + .filter((node): node is OutputSelectorData => !!node); + + return nodes.length && addTypeName + ? this.addTypeNameField(options, nodes) + : nodes; + } + + protected trackVisitedTypes( + type: T, + options: OutputSelectorOptions + ): OutputSelectorData[] { + const { visitedTypes } = options; + + return this.buildFields(type, { + ...options, + visitedTypes: [...visitedTypes, type.name] + }); + } + + protected findType( + schema: IntrospectionSchema, + typeRef: IntrospectionNamedTypeRef + ): T | undefined { + const [introspectionType]: T[] = schema.types.filter( + (type): type is T => type.kind === this.kind && type.name === typeRef.name + ); + + return introspectionType; + } + + protected createInlineSpread( + type: IntrospectionInterfaceType | IntrospectionObjectType, + options: OutputSelectorOptions + ): OutputSelectorData { + const { visitedTypes } = options; + + return { + typeRef: type, + args: [], + primitive: false, + name: `... on ${type.name}`, + options: { + ...options, + addTypeName: true, + visitedTypes: [...visitedTypes] + } + }; + } + + private addTypeNameField( + options: OutputSelectorOptions, + nodes: OutputSelectorData[] + ): OutputSelectorData[] { + return [ + { + options, + name: '__typename', + typeRef: { kind: 'SCALAR', name: 'String' }, + args: [], + primitive: true + }, + ...nodes + ]; + } +} diff --git a/packages/graphql/src/converter/output-selectors/InterfaceSelector.ts b/packages/graphql/src/converter/output-selectors/InterfaceSelector.ts new file mode 100644 index 00000000..39a39ff4 --- /dev/null +++ b/packages/graphql/src/converter/output-selectors/InterfaceSelector.ts @@ -0,0 +1,60 @@ +import { + type OutputSelectorData, + type OutputSelectorOptions +} from './OutputSelector'; +import { AbstractSelector } from './AbstractSelector'; +import { + type IntrospectionInterfaceType, + type IntrospectionObjectType, + type IntrospectionSchema +} from '@har-sdk/core'; + +export class InterfaceSelector extends AbstractSelector { + constructor() { + super('INTERFACE'); + } + + protected override buildFields( + type: IntrospectionInterfaceType, + options: OutputSelectorOptions + ): OutputSelectorData[] { + return [ + ...this.selectOwnFields(type, options), + ...this.selectSpreads(type, { + ...options, + excludeFields: type.fields.map((field) => field.name) + }) + ]; + } + + private selectSpreads( + type: IntrospectionInterfaceType, + options: OutputSelectorOptions + ): OutputSelectorData[] { + const { schema } = options; + + return this.findDescendantTypes(schema, type).map( + ( + descendantType: IntrospectionInterfaceType | IntrospectionObjectType + ): OutputSelectorData => this.createInlineSpread(descendantType, options) + ); + } + + private findDescendantTypes( + schema: IntrospectionSchema, + type: IntrospectionInterfaceType + ) { + return schema.types.filter( + ( + introspectionType + ): introspectionType is + | IntrospectionInterfaceType + | IntrospectionObjectType => + (introspectionType.kind === 'INTERFACE' || + introspectionType.kind === 'OBJECT') && + introspectionType.interfaces.some( + (interfaceType) => interfaceType.name === type.name + ) + ); + } +} diff --git a/packages/graphql/src/converter/output-selectors/ObjectSelector.ts b/packages/graphql/src/converter/output-selectors/ObjectSelector.ts new file mode 100644 index 00000000..fe892a13 --- /dev/null +++ b/packages/graphql/src/converter/output-selectors/ObjectSelector.ts @@ -0,0 +1,19 @@ +import { + type OutputSelectorData, + type OutputSelectorOptions +} from './OutputSelector'; +import { AbstractSelector } from './AbstractSelector'; +import { type IntrospectionObjectType } from '@har-sdk/core'; + +export class ObjectSelector extends AbstractSelector { + constructor() { + super('OBJECT'); + } + + protected override buildFields( + type: IntrospectionObjectType, + options: OutputSelectorOptions + ): OutputSelectorData[] { + return this.selectOwnFields(type, options); + } +} diff --git a/packages/graphql/src/converter/output-selectors/OutputSelector.ts b/packages/graphql/src/converter/output-selectors/OutputSelector.ts new file mode 100644 index 00000000..34ed49c3 --- /dev/null +++ b/packages/graphql/src/converter/output-selectors/OutputSelector.ts @@ -0,0 +1,33 @@ +import { + type IntrospectionInputValue, + type IntrospectionNamedTypeRef, + type IntrospectionOutputType, + type IntrospectionSchema +} from '@har-sdk/core'; + +export interface OutputSelectorOptions { + readonly schema: IntrospectionSchema; + readonly visitedTypes: readonly string[]; + readonly addTypeName?: boolean; + readonly excludeFields?: readonly string[]; +} + +export interface OutputSelectorData { + readonly name: string; + readonly typeRef: IntrospectionNamedTypeRef; + readonly args: readonly IntrospectionInputValue[]; + readonly primitive: boolean; + readonly options: OutputSelectorOptions; + estimatedCost?: number; +} + +export interface OutputSelector { + supportsType( + typeRef: IntrospectionNamedTypeRef + ): typeRef is IntrospectionNamedTypeRef; + + select( + typeRef: IntrospectionNamedTypeRef, + options: OutputSelectorOptions + ): OutputSelectorData[]; +} diff --git a/packages/graphql/src/converter/output-selectors/OutputSelectors.ts b/packages/graphql/src/converter/output-selectors/OutputSelectors.ts new file mode 100644 index 00000000..db12e339 --- /dev/null +++ b/packages/graphql/src/converter/output-selectors/OutputSelectors.ts @@ -0,0 +1,194 @@ +import { ConverterOptions } from '../ConverterOptions'; +import { OutputSelector, type OutputSelectorData } from './OutputSelector'; +import { GraphQlTypeRef } from '../GraphQlTypeRef'; +import { isGraphQLPrimitive } from '../../utils'; +import { OperationCostAnalyzer } from '../operations/OperationCostAnalyzer'; +import { InterfaceSelector } from './InterfaceSelector'; +import { ObjectSelector } from './ObjectSelector'; +import { UnionSelector } from './UnionSelector'; +import { PrimitiveSelector } from './PrimitiveSelector'; +import { + type IntrospectionField, + type IntrospectionNamedTypeRef, + type IntrospectionOutputType, + type IntrospectionSchema +} from '@har-sdk/core'; +import { createHash } from 'crypto'; + +export interface OutputSelectorTreeNode { + value: OutputSelectorData; + children: OutputSelectorTreeNode[]; + parent?: OutputSelectorTreeNode; +} + +export class OutputSelectors { + constructor( + private readonly outputSelectors: OutputSelector[] = [ + new InterfaceSelector(), + new ObjectSelector(), + new UnionSelector(), + new PrimitiveSelector() + ] + ) {} + + public create( + { + schema, + field + }: { + schema: IntrospectionSchema; + field: IntrospectionField; + }, + options: ConverterOptions + ): OutputSelectorTreeNode[] { + const { typeRef: resolvedTypeRef } = new GraphQlTypeRef(field.type); + + const root: OutputSelectorData = { + name: field.name, + primitive: isGraphQLPrimitive(resolvedTypeRef), + typeRef: resolvedTypeRef, + args: field.args, + options: { schema, visitedTypes: [] } + }; + + const selections = [ + this.expandSelectionsInBreadth( + { + value: root, + children: [] + }, + new OperationCostAnalyzer(options) + ), + this.expandSelectionsInDepth( + { + value: root, + children: [] + }, + new OperationCostAnalyzer(options) + ) + ] + .filter( + (selectionRoot): selectionRoot is OutputSelectorTreeNode => + !!selectionRoot + ) + .map((selectionRoot): [string, OutputSelectorTreeNode] => [ + this.createDigest(selectionRoot), + selectionRoot + ]); + + return [...new Map(selections).values()]; + } + + private expandSelectionsInDepth( + root: OutputSelectorTreeNode, + analyzer: OperationCostAnalyzer + ) { + const stack: OutputSelectorTreeNode[] = [root]; + + analyzer.estimate(root); + + while (stack.length) { + const cursor = stack.pop(); + + if (!cursor || analyzer.done) { + break; + } + + this.expandNode(cursor, analyzer); + + stack.push(...cursor.children); + } + + return this.omitInvalidSubtrees(root); + } + + private expandSelectionsInBreadth( + root: OutputSelectorTreeNode, + analyzer: OperationCostAnalyzer + ) { + const queue: OutputSelectorTreeNode[] = [root]; + + analyzer.estimate(root); + + while (queue.length) { + const cursor = queue.shift(); + + if (!cursor || analyzer.done) { + break; + } + + this.expandNode(cursor, analyzer); + + queue.push(...cursor.children); + } + + return this.omitInvalidSubtrees(root); + } + + private expandNode( + node: OutputSelectorTreeNode, + analyzer: OperationCostAnalyzer + ) { + const selector = this.find(node.value.typeRef); + if (selector) { + const selectors = selector.select(node.value.typeRef, node.value.options); + + const children = selectors.map((value) => + analyzer.estimate({ + value, + parent: node, + children: [] + }) + ); + + node.children = children + .map((child) => (analyzer.analyze(child) ? child : undefined)) + .filter((child): child is OutputSelectorTreeNode => !!child); + } + } + + private omitInvalidSubtrees( + node: OutputSelectorTreeNode + ): OutputSelectorTreeNode | undefined { + if (this.isValidSubtree(node)) { + node.children = node.children + .map((child) => this.omitInvalidSubtrees(child)) + .filter((child): child is OutputSelectorTreeNode => !!child); + + return node; + } + + return undefined; + } + + private isValidSubtree(node: OutputSelectorTreeNode): boolean { + return ( + node.value.primitive || node.children.some((n) => this.isValidSubtree(n)) + ); + } + + private createDigest(root: OutputSelectorTreeNode): string { + const hash = createHash('sha256'); + const stack: OutputSelectorTreeNode[] = [root]; + + while (stack.length) { + const cursor = stack.pop(); + + if (!cursor) { + break; + } + + hash.update(cursor.value.name); + + stack.push(...cursor.children); + } + + return hash.digest('hex'); + } + + private find( + typeRef: IntrospectionNamedTypeRef + ): OutputSelector | undefined { + return this.outputSelectors.find((s) => s.supportsType(typeRef)); + } +} diff --git a/packages/graphql/src/converter/output-selectors/PrimitiveSelector.ts b/packages/graphql/src/converter/output-selectors/PrimitiveSelector.ts new file mode 100644 index 00000000..43b19ec1 --- /dev/null +++ b/packages/graphql/src/converter/output-selectors/PrimitiveSelector.ts @@ -0,0 +1,31 @@ +import { + OutputSelector, + type OutputSelectorData, + type OutputSelectorOptions +} from './OutputSelector'; +import { isGraphQLPrimitive } from '../../utils'; +import { + type IntrospectionEnumType, + type IntrospectionNamedTypeRef, + type IntrospectionOutputTypeRef, + type IntrospectionScalarType +} from '@har-sdk/core'; + +export class PrimitiveSelector + implements OutputSelector +{ + public supportsType( + typeRef: IntrospectionOutputTypeRef + ): typeRef is IntrospectionNamedTypeRef< + IntrospectionEnumType | IntrospectionScalarType + > { + return isGraphQLPrimitive(typeRef); + } + + public select( + _typeRef: IntrospectionOutputTypeRef, + _options: OutputSelectorOptions + ): OutputSelectorData[] { + return []; + } +} diff --git a/packages/graphql/src/converter/output-selectors/UnionSelector.ts b/packages/graphql/src/converter/output-selectors/UnionSelector.ts new file mode 100644 index 00000000..38b9db78 --- /dev/null +++ b/packages/graphql/src/converter/output-selectors/UnionSelector.ts @@ -0,0 +1,43 @@ +import { + type OutputSelectorData, + type OutputSelectorOptions +} from './OutputSelector'; +import { AbstractSelector } from './AbstractSelector'; +import { + type IntrospectionInterfaceType, + type IntrospectionObjectType, + type IntrospectionSchema, + type IntrospectionUnionType +} from '@har-sdk/core'; + +export class UnionSelector extends AbstractSelector { + constructor() { + super('UNION'); + } + + protected buildFields( + type: IntrospectionUnionType, + options: OutputSelectorOptions + ): OutputSelectorData[] { + const { schema } = options; + + return this.findPossibleTypes(schema, type).map( + ( + possibleType: IntrospectionInterfaceType | IntrospectionObjectType + ): OutputSelectorData => this.createInlineSpread(possibleType, options) + ); + } + + private findPossibleTypes( + schema: IntrospectionSchema, + unionType: IntrospectionUnionType + ) { + return schema.types.filter( + (introspectionType): introspectionType is IntrospectionObjectType => + introspectionType.kind === 'OBJECT' && + unionType.possibleTypes.some( + (possibleType) => possibleType.name === introspectionType.name + ) + ); + } +} diff --git a/packages/graphql/src/converter/output-selectors/index.ts b/packages/graphql/src/converter/output-selectors/index.ts new file mode 100644 index 00000000..f2aa0491 --- /dev/null +++ b/packages/graphql/src/converter/output-selectors/index.ts @@ -0,0 +1,2 @@ +export * from './OutputSelector'; +export * from './OutputSelectors'; diff --git a/packages/graphql/src/index.ts b/packages/graphql/src/index.ts index e69de29b..9eb1267d 100644 --- a/packages/graphql/src/index.ts +++ b/packages/graphql/src/index.ts @@ -0,0 +1,13 @@ +import { ConverterOptions, DefaultConverter } from './converter'; +import { GraphQL, Request } from '@har-sdk/core'; + +export { ConverterOptions } from './converter'; + +export const graphql2har = async ( + doc: GraphQL.Document, + options: ConverterOptions = {} +): Promise => { + const converter = new DefaultConverter(); + + return converter.convert(doc, options); +}; diff --git a/packages/graphql/src/utils/graphql-parse-value.ts b/packages/graphql/src/utils/graphql-parse-value.ts new file mode 100644 index 00000000..83489a1d --- /dev/null +++ b/packages/graphql/src/utils/graphql-parse-value.ts @@ -0,0 +1,71 @@ +import { type ASTNode, Kind, parseValue, visit } from '@har-sdk/core'; + +const getNodeValue = (valueNode: ASTNode): unknown => { + switch (valueNode.kind) { + case Kind.INT: + return parseInt(valueNode.value, 10); + case Kind.FLOAT: + return parseFloat(valueNode.value); + case Kind.BOOLEAN: + return valueNode.value; + case Kind.STRING: + case Kind.ENUM: + return valueNode.value; + case Kind.NULL: + return null; + case Kind.LIST: + return []; + case Kind.OBJECT: + return {}; + case Kind.OBJECT_FIELD: + return valueNode.name.value; + default: + return undefined; + } +}; + +export const graphQLParseValue = (source: string): unknown => { + const valueNode: ASTNode = parseValue(source); + const resultStack: unknown[] = []; + const nodeStack: ASTNode[] = []; + + visit(valueNode, { + enter(node) { + const nodeValue = getNodeValue(node); + + if (nodeStack.length > 0) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const parentNode = nodeStack[nodeStack.length - 1]!; + + switch (parentNode.kind) { + case Kind.OBJECT_FIELD: + // eslint-disable-next-line no-case-declarations + const parentObject = resultStack[resultStack.length - 2] as Record< + string, + unknown + >; + parentObject[parentNode.name.value] = nodeValue; + break; + case Kind.LIST: + // eslint-disable-next-line no-case-declarations + const parentArray = resultStack[ + resultStack.length - 1 + ] as unknown[]; + parentArray.push(nodeValue); + break; + } + } + + nodeStack.push(node); + resultStack.push(nodeValue); + }, + leave(_) { + nodeStack.pop(); + if (resultStack.length > 1) { + resultStack.pop(); + } + } + }); + + return resultStack.shift(); +}; diff --git a/packages/graphql/src/utils/index.ts b/packages/graphql/src/utils/index.ts new file mode 100644 index 00000000..663c1a71 --- /dev/null +++ b/packages/graphql/src/utils/index.ts @@ -0,0 +1,2 @@ +export { isGraphQLPrimitive } from './is-graphql-primitive'; +export { graphQLParseValue } from './graphql-parse-value'; diff --git a/packages/graphql/src/utils/is-graphql-primitive.ts b/packages/graphql/src/utils/is-graphql-primitive.ts new file mode 100644 index 00000000..2d8bfb3a --- /dev/null +++ b/packages/graphql/src/utils/is-graphql-primitive.ts @@ -0,0 +1,12 @@ +import { + type IntrospectionEnumType, + type IntrospectionNamedTypeRef, + type IntrospectionOutputTypeRef, + type IntrospectionScalarType +} from '@har-sdk/core'; + +export const isGraphQLPrimitive = ( + typeRef: IntrospectionOutputTypeRef +): typeRef is IntrospectionNamedTypeRef< + IntrospectionEnumType | IntrospectionScalarType +> => typeRef.kind === 'SCALAR' || typeRef.kind === 'ENUM'; diff --git a/packages/graphql/tests/DefaultConverter.spec.ts b/packages/graphql/tests/DefaultConverter.spec.ts new file mode 100644 index 00000000..0979fa8a --- /dev/null +++ b/packages/graphql/tests/DefaultConverter.spec.ts @@ -0,0 +1,45 @@ +import { GraphQLFixture } from './GraphQLFixture'; +import { DefaultConverter } from '../src/converter/DefaultConverter'; + +describe('DefaultConverter', () => { + const graphQLFixture = new GraphQLFixture(); + + let sut!: DefaultConverter; + + beforeEach(() => { + sut = new DefaultConverter(); + }); + + describe('convert', () => { + it.each([ + { + test: 'brokencrystals', + input: { + url: 'https://brokencrystals.com/graphql', + fileNames: ['brokencrystals'] + }, + expectedFileName: 'brokencrystals.har-requests.result' + }, + { + test: 'file-upload', + input: { + url: 'https://example.com/graphql', + fileNames: ['file-upload'] + }, + expectedFileName: 'file-upload.har-requests.result' + } + ])('should convert $test', async ({ input, expectedFileName }) => { + // arrange + const { expected, input: inputDocument } = await graphQLFixture.create({ + expectedFileName, + ...input + }); + + // act + const result = await sut.convert(inputDocument, {}); + + // assert + expect(result).toStrictEqual(expected); + }); + }); +}); diff --git a/packages/graphql/tests/DefaultOperations.spec.ts b/packages/graphql/tests/DefaultOperations.spec.ts new file mode 100644 index 00000000..4a464947 --- /dev/null +++ b/packages/graphql/tests/DefaultOperations.spec.ts @@ -0,0 +1,208 @@ +import { DefaultOperations } from '../src/converter/operations/DefaultOperations'; +import { GraphQLFixture } from './GraphQLFixture'; + +describe('DefaultGraphQLOperations', () => { + const graphQLFixture = new GraphQLFixture(); + + let sut!: DefaultOperations; + + beforeEach(() => { + sut = new DefaultOperations(); + }); + + describe('create', () => { + it.each([ + { + test: 'UPLOAD output', + input: { + fileNames: ['file-upload'], + options: { skipExternalizedVariables: true, skipInPlaceValues: true } + }, + expected: 'file-upload.result' + }, + { + test: 'UNION output with externalized variables', + input: { + fileNames: ['output-selector.union', 'star-wars.models'], + options: { skipInPlaceValues: true } + }, + expected: 'output-selector.union.variables.result' + }, + { + test: 'LIST input with externalized variables', + input: { + fileNames: ['input-sampler.list', 'star-wars.models'], + options: { skipInPlaceValues: true } + }, + expected: 'input-sampler.list.variables.result' + }, + { + test: 'INPUT_OBJECT input with externalized variables', + input: { + fileNames: ['input-sampler.input-object', 'star-wars.models'], + options: { skipInPlaceValues: true } + }, + expected: 'input-sampler.input-object.variables.result' + }, + { + test: 'ENUM input with externalized variables', + input: { + fileNames: ['input-sampler.enum', 'star-wars.models'], + options: { skipInPlaceValues: true } + }, + expected: 'input-sampler.enum.variables.result' + }, + { + test: 'builtin SCALAR input with externalized variables', + input: { + fileNames: ['input-sampler.builtin-scalar'], + options: { skipInPlaceValues: true } + }, + expected: 'input-sampler.builtin-scalar.variables.result' + }, + { + test: 'OBJECT output with externalized variables', + input: { + fileNames: ['output-selector.object', 'star-wars.models'], + options: { skipInPlaceValues: true } + }, + expected: 'output-selector.object.variables.result' + }, + { + test: 'INTERFACE output with externalized variables', + input: { + fileNames: ['output-selector.interface', 'star-wars.models'], + options: { skipInPlaceValues: true } + }, + expected: 'output-selector.interface.variables.result' + }, + { + test: 'UNION output with in place arguments', + input: { + fileNames: ['output-selector.union', 'star-wars.models'], + options: { skipExternalizedVariables: true } + }, + expected: 'output-selector.union.in-place.result' + }, + { + test: 'LIST input with in place arguments', + input: { + fileNames: ['input-sampler.list', 'star-wars.models'], + options: { skipExternalizedVariables: true } + }, + expected: 'input-sampler.list.in-place.result' + }, + { + test: 'INPUT_OBJECT input with in place arguments', + input: { + fileNames: ['input-sampler.input-object', 'star-wars.models'], + options: { skipExternalizedVariables: true } + }, + expected: 'input-sampler.input-object.in-place.result' + }, + { + test: 'ENUM input with in place arguments', + input: { + fileNames: ['input-sampler.enum', 'star-wars.models'], + options: { skipExternalizedVariables: true } + }, + expected: 'input-sampler.enum.in-place.result' + }, + { + test: 'builtin SCALAR input with in place arguments', + input: { + fileNames: ['input-sampler.builtin-scalar'], + options: { skipExternalizedVariables: true } + }, + expected: 'input-sampler.builtin-scalar.in-place.result' + }, + { + test: 'OBJECT output with in place arguments', + input: { + fileNames: ['output-selector.object', 'star-wars.models'], + options: { skipExternalizedVariables: true } + }, + expected: 'output-selector.object.in-place.result' + }, + { + test: 'INTERFACE output with in place arguments', + input: { + fileNames: ['output-selector.interface', 'star-wars.models'], + options: { skipExternalizedVariables: true } + }, + expected: 'output-selector.interface.in-place.result' + } + ])( + 'should convert $test', + async ({ input, expected: expectedFileName }) => { + // arrange + const { input: inputEnvelope, expected } = await graphQLFixture.create({ + ...input, + expectedFileName + }); + + // act + const result = sut.create(inputEnvelope.data, input.options); + + // assert + expect(result).toStrictEqual(expected); + } + ); + + it.each([ + { + test: 'DVGA with in place arguments', + input: { + fileNames: ['dvga'], + options: { + skipExternalizedVariables: true, + operationCostThreshold: 10 + } + }, + expected: 'dvga.in-place.result' + }, + { + test: 'DVGA with externalized variables', + input: { + fileNames: ['dvga'], + options: { skipInPlaceValues: true, operationCostThreshold: 10 } + }, + expected: 'dvga.variables.result' + }, + { + test: 'https://countries.trevorblades.com with in place arguments', + input: { + fileNames: ['trevorblades'], + options: { + skipExternalizedVariables: true, + operationCostThreshold: 10 + } + }, + expected: 'trevorblades.in-place.result' + }, + { + test: 'https://countries.trevorblades.com with externalized variables', + input: { + fileNames: ['trevorblades'], + options: { skipInPlaceValues: true, operationCostThreshold: 10 } + }, + expected: 'trevorblades.variables.result' + } + ])( + 'should convert 3rd party specification $test', + async ({ input: { fileNames, options }, expected: expectedFileName }) => { + // arrange + const { input: inputEnvelope, expected } = await graphQLFixture.create({ + fileNames, + expectedFileName + }); + + // act + const result = sut.create(inputEnvelope.data, options); + + // assert + expect(result).toStrictEqual(expected); + } + ); + }); +}); diff --git a/packages/graphql/tests/GraphQLFixture.ts b/packages/graphql/tests/GraphQLFixture.ts new file mode 100644 index 00000000..17299312 --- /dev/null +++ b/packages/graphql/tests/GraphQLFixture.ts @@ -0,0 +1,60 @@ +import { GraphQL } from '@har-sdk/core'; +import { introspectionFromSchema } from 'graphql'; +import { GraphQLFileLoader } from '@graphql-tools/graphql-file-loader'; +import { loadSchema } from '@graphql-tools/load'; +import { resolve } from 'path'; +import { readFile } from 'fs'; +import { promisify } from 'util'; + +export class GraphQLFixture { + public async create({ + fileNames, + url = 'https://example.com/graphql', + expectedFileName + }: { + fileNames: string[]; + expectedFileName: string; + url?: string; + }) { + const inputFilePath = resolve(__dirname, `./fixtures`); + + const expectedContent = await promisify(readFile)( + `${inputFilePath}/${expectedFileName}.json`, + 'utf-8' + ); + + const input = await this.createFromSDL({ fileNames, url }); + + const inputContent = JSON.stringify(input); + + return { + input, + inputContent, + expected: JSON.parse(expectedContent) + }; + } + + public async createFromSDL({ + fileNames, + url = 'https://exmaple.com/graphql' + }: { + fileNames: string | string[]; + url?: string; + }) { + const inputFilePath = resolve(__dirname, `./fixtures`); + + fileNames = Array.isArray(fileNames) ? fileNames : [fileNames]; + + const schema = await loadSchema( + fileNames.map((name) => `${inputFilePath}/${name}.graphql`), + { + loaders: [new GraphQLFileLoader()] + } + ); + + return { + url, + data: introspectionFromSchema(schema) + } as GraphQL.Document; + } +} diff --git a/packages/graphql/tests/ScalarSampler.spec.ts b/packages/graphql/tests/ScalarSampler.spec.ts new file mode 100644 index 00000000..be393cd2 --- /dev/null +++ b/packages/graphql/tests/ScalarSampler.spec.ts @@ -0,0 +1,166 @@ +import { ScalarSampler } from '../src/converter/input-samplers/ScalarSampler'; +import { type InputSamplerOptions } from '../src/converter/input-samplers/InputSampler'; +import { graphQLParseValue } from '../src/utils/graphql-parse-value'; +import { type IntrospectionInputTypeRef } from 'graphql'; + +describe('ScalarSampler', () => { + let sut!: ScalarSampler; + + beforeEach(() => { + sut = new ScalarSampler(); + }); + + describe('sample', () => { + it.each([ + { input: 'String', expected: '"lorem"' }, + { input: 'Int', expected: '42' }, + { input: 'Boolean', expected: 'true' }, + { input: 'Float', expected: '123.45' }, + { input: 'ID', expected: '"f323fed3-ae3e-41df-abe2-540859417876"' }, + { input: 'Date', expected: '"2023-12-17"' }, + { input: 'Time', expected: '"09:09:06.13Z"' }, + { input: 'DateTime', expected: '"2023-02-01T00:00:00.000Z"' }, + { input: 'DateTimeISO', expected: '"2023-02-01T00:00:00.000Z"' }, + { input: 'Timestamp', expected: '1705494979' }, + { input: 'TimeZone', expected: '"America/Chicago"' }, + { input: 'UtcOffset', expected: '"+05:00"' }, + { input: 'Duration', expected: '"PT65H40M22S"' }, + { input: 'ISO8601Duration', expected: '"P1D"' }, + { input: 'LocalDate', expected: '"2023-01-01"' }, + { input: 'LocalTime', expected: '"23:59:59.999"' }, + { input: 'LocalDateTime', expected: '"2023-01-01T12:00:00+01:00"' }, + { input: 'LocalEndTime', expected: '"23:59:59.999"' }, + { input: 'EmailAddress', expected: '"root@example.com"' }, + { input: 'NegativeFloat', expected: '-123.45' }, + { input: 'NegativeInt', expected: '-42' }, + { input: 'NonEmptyString', expected: 'lorem' }, + { input: 'NonNegativeFloat', expected: '0.0' }, + { input: 'NonNegativeInt', expected: '0' }, + { input: 'NonPositiveFloat', expected: '0.0' }, + { input: 'NonPositiveInt', expected: '0' }, + { input: 'PhoneNumber', expected: '"+16075551234"' }, + { input: 'PositiveFloat', expected: '123.45' }, + { input: 'PositiveInt', expected: '42' }, + { input: 'PostalCode', expected: '"60031"' }, + { input: 'UnsignedFloat', expected: '123.45' }, + { input: 'UnsignedInt', expected: '42' }, + { input: 'URL', expected: '"https://example.com"' }, + { input: 'BigInt', expected: '9007199254740992' }, + { input: 'Long', expected: '42' }, + { input: 'Byte', expected: '"ff"' }, + { input: 'UUID', expected: '"6d400c98-207b-416b-885b-1b1812c25d18"' }, + { input: 'GUID', expected: '"e982c909-c347-4fbc-8ebc-e42ae5cb3312"' }, + { input: 'Hexadecimal', expected: '"123456789ABCDEF"' }, + { input: 'HexColorCode', expected: '"#ffcc00"' }, + { input: 'HSL', expected: '"hsl(270, 60%, 70%)"' }, + { input: 'HSLA', expected: '"hsla(240, 100%, 50%, .05)"' }, + { input: 'IP', expected: '"127.0.0.1"' }, + { input: 'IPv4', expected: '"127.0.0.1"' }, + { input: 'IPv6', expected: '"1080:0:0:0:8:800:200C:417A"' }, + { input: 'ISBN', expected: '"ISBN 978-0615-856"' }, + { + input: 'JWT', + expected: + '"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaWF0IjoxNTE2MjM5MDIyLCJwcm9qZWN0IjoiZ3JhcGhxbC1zY2FsYXJzIn0.nYdrSfE2nNRAgpiEU1uKgn2AYYKLo28Z0nhPXvsuIww"' + }, + { input: 'Latitude', expected: '41.89193' }, + { input: 'Longitude', expected: '-74.00597' }, + { input: 'MAC', expected: '"01:23:45:67:89:ab"' }, + { input: 'Port', expected: '1337' }, + { input: 'RGB', expected: '"rgb(255, 0, 153)"' }, + { input: 'RGBA', expected: '"rgba(51, 170, 51, .7)"' }, + { input: 'SafeInt', expected: '42' }, + { input: 'USCurrency', expected: '"$22,900.00"' }, + { input: 'Currency', expected: '"USD"' }, + { input: 'JSON', expected: '{foo:{bar:"baz"}}' }, + { input: 'JSONObject', expected: '{foo:{bar:"baz"}}' }, + { input: 'IBAN', expected: '"GE29NB0000000101904917"' }, + { input: 'ObjectID', expected: '"5e5677d71bdc2ae76344968c"' }, + { input: 'DID', expected: '"did:example:123456789abcdefghi"' }, + { input: 'CountryCode', expected: '"US"' }, + { input: 'Locale', expected: '"en-US"' }, + { input: 'RoutingNumber', expected: '"111000025"' }, + { input: 'AccountNumber', expected: '"1234567890ABCDEF1"' }, + { input: 'Cuid', expected: '"cjld2cyuq0000t3rmniod1foy"' }, + { input: 'SemVer', expected: '"1.2.3"' }, + { input: 'DeweyDecimal', expected: '1234.56' }, + { input: 'LCCSubclass', expected: '"KBM"' }, + { input: 'IPCPatent', expected: '"F16K 27/02"' }, + { input: 'Regex', expected: '"^(.*)$"' }, + { input: 'SByte', expected: '"ef"' }, + { input: 'SignedByte', expected: '"ef"' }, + { input: 'Char', expected: '"a"' }, + { input: 'Single', expected: '123.45' }, + { input: 'Half', expected: '123.45' }, + { input: 'Double', expected: '123.45' }, + { input: 'Decimal', expected: '123.45' }, + { input: 'BigDecimal', expected: '123.45' }, + { input: 'Int16', expected: '42' }, + { input: 'Short', expected: '42' }, + { input: 'UInt16', expected: '42' }, + { input: 'UnsignedShort', expected: '42' }, + { input: 'Int32', expected: '42' }, + { input: 'UInt32', expected: '42' }, + { input: 'UnsignedInt', expected: '42' }, + { input: 'Int64', expected: '42' }, + { input: 'Long', expected: '42' }, + { input: 'UInt64', expected: '42' }, + { input: 'UnsignedLong', expected: '42' }, + { input: 'TimeSpan', expected: '"00:00:10"' }, + { input: 'DateTimeOffset', expected: '"2009-06-15T13:45:30.000Z"' }, + { input: 'Uri', expected: '"https://example.com/api/v1"' } + ])('should sample $input', ({ input, expected }) => { + // arrange + const typeRef: IntrospectionInputTypeRef = { + kind: 'SCALAR', + name: input + }; + + // act + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const result = sut.sample(typeRef, {} as unknown as InputSamplerOptions)!; + + // assert + expect(result).toBe(expected); + expect(() => graphQLParseValue(result)).not.toThrow(); + }); + + it('should return null when type is unknown', () => { + // arrange + const typeRef: IntrospectionInputTypeRef = { + kind: 'SCALAR', + name: 'SomeUnknownType' + }; + + // act + const result = sut.sample(typeRef, {} as unknown as InputSamplerOptions); + + // assert + expect(result).toBe('null'); + }); + + it.each([ + { input: 'UnsignedLongLong', expected: '42' }, + { input: 'FuzzyDateInt', expected: '42' }, + { input: 'ValidatedEmailAddress', expected: '"root@example.com"' } + ])( + 'should return a value when $input is similar to known', + ({ input, expected }) => { + // arrange + const typeRef: IntrospectionInputTypeRef = { + kind: 'SCALAR', + name: input + }; + + // act + const result = sut.sample( + typeRef, + {} as unknown as InputSamplerOptions + ); + + // assert + expect(result).toBe(expected); + } + ); + }); +}); diff --git a/packages/graphql/tests/fixtures/brokencrystals.graphql b/packages/graphql/tests/fixtures/brokencrystals.graphql new file mode 100644 index 00000000..f13d5ece --- /dev/null +++ b/packages/graphql/tests/fixtures/brokencrystals.graphql @@ -0,0 +1,53 @@ +input CreateTestimonialRequest { + name: String! + title: String! + message: String! +} + +type Mutation { + """ + Creates testimonial + """ + createTestimonial(testimonialRequest: CreateTestimonialRequest!): Testimonial! + + """ + Updates the product's 'viewsCount' according to product name provided in the header 'x-product-name' and returns the query result. + """ + viewProduct(productName: String!): Boolean! +} + +type Product { + name: String! + category: String! + photoUrl: String! + description: String! + viewsCount: Int! +} + +type Query { + """ + Returns all testimonials + """ + allTestimonials: [Testimonial!]! + + """ + Returns count of all testimonials based on provided sql query + """ + testimonialsCount(query: String!): Int! + + """ + Returns all products + """ + allProducts: [Product!]! + + """ + Returns 3 latest products + """ + latestProducts: [Product!]! +} + +type Testimonial { + name: String! + title: String! + message: String! +} diff --git a/packages/graphql/tests/fixtures/brokencrystals.har-requests.result.json b/packages/graphql/tests/fixtures/brokencrystals.har-requests.result.json new file mode 100644 index 00000000..c31f827c --- /dev/null +++ b/packages/graphql/tests/fixtures/brokencrystals.har-requests.result.json @@ -0,0 +1,173 @@ +[ + { + "url": "https://brokencrystals.com/graphql", + "method": "POST", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "content-type", + "value": "application/json" + } + ], + "queryString": [], + "headersSize": -1, + "bodySize": -1, + "postData": { + "mimeType": "application/json", + "text": "{\"query\":\"mutation { createTestimonial(testimonialRequest: {name: \\\"lorem\\\", title: \\\"lorem\\\", message: \\\"lorem\\\"}) { name title message } }\"}" + } + }, + { + "url": "https://brokencrystals.com/graphql", + "method": "POST", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "content-type", + "value": "application/json" + } + ], + "queryString": [], + "headersSize": -1, + "bodySize": -1, + "postData": { + "mimeType": "application/json", + "text": "{\"operationName\":\"CreateTestimonial\",\"query\":\"mutation CreateTestimonial($testimonialRequest: CreateTestimonialRequest!) { createTestimonial(testimonialRequest: $testimonialRequest) { name title message } }\",\"variables\":{\"testimonialRequest\":{\"name\":\"lorem\",\"title\":\"lorem\",\"message\":\"lorem\"}}}" + } + }, + { + "url": "https://brokencrystals.com/graphql", + "method": "POST", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "content-type", + "value": "application/json" + } + ], + "queryString": [], + "headersSize": -1, + "bodySize": -1, + "postData": { + "mimeType": "application/json", + "text": "{\"query\":\"mutation { viewProduct(productName: \\\"lorem\\\") }\"}" + } + }, + { + "url": "https://brokencrystals.com/graphql", + "method": "POST", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "content-type", + "value": "application/json" + } + ], + "queryString": [], + "headersSize": -1, + "bodySize": -1, + "postData": { + "mimeType": "application/json", + "text": "{\"operationName\":\"ViewProduct\",\"query\":\"mutation ViewProduct($productName: String!) { viewProduct(productName: $productName) }\",\"variables\":{\"productName\":\"lorem\"}}" + } + }, + { + "url": "https://brokencrystals.com/graphql", + "method": "POST", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "content-type", + "value": "application/json" + } + ], + "queryString": [], + "headersSize": -1, + "bodySize": -1, + "postData": { + "mimeType": "application/json", + "text": "{\"query\":\"query { allTestimonials { name title message } }\"}" + } + }, + { + "url": "https://brokencrystals.com/graphql", + "method": "POST", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "content-type", + "value": "application/json" + } + ], + "queryString": [], + "headersSize": -1, + "bodySize": -1, + "postData": { + "mimeType": "application/json", + "text": "{\"query\":\"query { testimonialsCount(query: \\\"lorem\\\") }\"}" + } + }, + { + "url": "https://brokencrystals.com/graphql", + "method": "POST", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "content-type", + "value": "application/json" + } + ], + "queryString": [], + "headersSize": -1, + "bodySize": -1, + "postData": { + "mimeType": "application/json", + "text": "{\"operationName\":\"TestimonialsCount\",\"query\":\"query TestimonialsCount($query: String!) { testimonialsCount(query: $query) }\",\"variables\":{\"query\":\"lorem\"}}" + } + }, + { + "url": "https://brokencrystals.com/graphql", + "method": "POST", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "content-type", + "value": "application/json" + } + ], + "queryString": [], + "headersSize": -1, + "bodySize": -1, + "postData": { + "mimeType": "application/json", + "text": "{\"query\":\"query { allProducts { name category photoUrl description viewsCount } }\"}" + } + }, + { + "url": "https://brokencrystals.com/graphql", + "method": "POST", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "content-type", + "value": "application/json" + } + ], + "queryString": [], + "headersSize": -1, + "bodySize": -1, + "postData": { + "mimeType": "application/json", + "text": "{\"query\":\"query { latestProducts { name category photoUrl description viewsCount } }\"}" + } + } +] diff --git a/packages/graphql/tests/fixtures/dvga.graphql b/packages/graphql/tests/fixtures/dvga.graphql new file mode 100644 index 00000000..cc269479 --- /dev/null +++ b/packages/graphql/tests/fixtures/dvga.graphql @@ -0,0 +1,129 @@ +schema { + query: Query + mutation: Mutations + subscription: Subscription +} + +""" +Displays the network associated with an IP Address (CIDR or Net). +""" +directive @show_network( + style: String! +) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT + +type Query { + pastes(public: Boolean, limit: Int, filter: String): [PasteObject] + paste(id: Int, title: String): PasteObject + systemUpdate: String + systemDiagnostics(username: String, password: String, cmd: String): String + systemDebug(arg: String): String + systemHealth: String + users(id: Int): [UserObject] + readAndBurn(id: Int): PasteObject + search(keyword: String): [SearchResult] + audits: [AuditObject] + deleteAllPastes: Boolean + me(token: String): UserObject +} + +type PasteObject { + id: ID! + title: String + content: String + public: Boolean + userAgent: String + ipAddr: String + ownerId: Int + burn: Boolean + owner: OwnerObject +} + +type OwnerObject { + id: ID! + name: String + paste: [PasteObject] + pastes: [PasteObject] +} + +type UserObject { + id: ID! + username(capitalize: Boolean): String + password: String! +} + +union SearchResult = PasteObject | UserObject + +type AuditObject { + id: ID! + gqloperation: String + gqlquery: String + timestamp: DateTime +} + +""" +The `DateTime` scalar type represents a DateTime +value as specified by +[iso8601](https://en.wikipedia.org/wiki/ISO_8601). +""" +scalar DateTime + +type Mutations { + createPaste( + burn: Boolean = false + content: String + public: Boolean = true + title: String + ): CreatePaste + editPaste(content: String, id: Int, title: String): EditPaste + deletePaste(id: Int): DeletePaste + uploadPaste(content: String!, filename: String!): UploadPaste + importPaste( + host: String! + path: String! + port: Int + scheme: String! + ): ImportPaste + createUser(userData: UserInput!): CreateUser + login(password: String, username: String): Login +} + +type CreatePaste { + paste: PasteObject +} + +type EditPaste { + paste: PasteObject +} + +type DeletePaste { + result: Boolean +} + +type UploadPaste { + content: String + filename: String + result: String +} + +type ImportPaste { + result: String +} + +type CreateUser { + user: UserObject +} + +input UserInput { + username: String! + email: String! + password: String! +} + +type Login { + accessToken: String + refreshToken: String +} + +type Subscription { + paste(id: Int, title: String): PasteObject +} diff --git a/packages/graphql/tests/fixtures/dvga.in-place.result.json b/packages/graphql/tests/fixtures/dvga.in-place.result.json new file mode 100644 index 00000000..fb59e3c2 --- /dev/null +++ b/packages/graphql/tests/fixtures/dvga.in-place.result.json @@ -0,0 +1,65 @@ +[ + { + "query": "query { pastes(public: true,limit: 42,filter: \"lorem\") { id title content public userAgent ipAddr } }" + }, + { + "query": "query { paste(id: 42,title: \"lorem\") { id title content public userAgent ipAddr ownerId } }" + }, + { + "query": "query { systemUpdate }" + }, + { + "query": "query { systemDiagnostics(username: \"lorem\",password: \"lorem\",cmd: \"lorem\") }" + }, + { + "query": "query { systemDebug(arg: \"lorem\") }" + }, + { + "query": "query { systemHealth }" + }, + { + "query": "query { users(id: 42) { id username(capitalize: true) password } }" + }, + { + "query": "query { readAndBurn(id: 42) { id title content public userAgent ipAddr ownerId burn } }" + }, + { + "query": "query { search(keyword: \"lorem\") { ... on PasteObject { __typename id title content public userAgent ipAddr ownerId } } }" + }, + { + "query": "query { search(keyword: \"lorem\") { ... on PasteObject { __typename id title } ... on UserObject { __typename id username(capitalize: true) password } } }" + }, + { + "query": "query { audits { id gqloperation gqlquery timestamp } }" + }, + { + "query": "query { deleteAllPastes }" + }, + { + "query": "query { me(token: \"lorem\") { id username(capitalize: true) password } }" + }, + { + "query": "mutations { createPaste(burn: false,content: \"lorem\",public: true,title: \"lorem\") { paste { id title content public userAgent } } }" + }, + { + "query": "mutations { editPaste(content: \"lorem\",id: 42,title: \"lorem\") { paste { id title content public userAgent ipAddr } } }" + }, + { + "query": "mutations { deletePaste(id: 42) { result } }" + }, + { + "query": "mutations { uploadPaste(content: \"lorem\",filename: \"lorem\") { content filename result } }" + }, + { + "query": "mutations { importPaste(host: \"lorem\",path: \"lorem\",port: 42,scheme: \"lorem\") { result } }" + }, + { + "query": "mutations { createUser(userData: {username: \"lorem\", email: \"lorem\", password: \"lorem\"}) { user { id username(capitalize: true) password } } }" + }, + { + "query": "mutations { login(password: \"lorem\",username: \"lorem\") { accessToken refreshToken } }" + }, + { + "query": "subscription { paste(id: 42,title: \"lorem\") { id title content public userAgent ipAddr ownerId } }" + } +] diff --git a/packages/graphql/tests/fixtures/dvga.variables.result.json b/packages/graphql/tests/fixtures/dvga.variables.result.json new file mode 100644 index 00000000..efcdf44c --- /dev/null +++ b/packages/graphql/tests/fixtures/dvga.variables.result.json @@ -0,0 +1,161 @@ +[ + { + "operationName": "Pastes", + "query": "query Pastes($public: Boolean, $limit: Int, $filter: String) { pastes(public: $public,limit: $limit,filter: $filter) { id title content public userAgent ipAddr } }", + "variables": { + "public": true, + "limit": 42, + "filter": "lorem" + } + }, + { + "operationName": "Paste", + "query": "query Paste($id: Int, $title: String) { paste(id: $id,title: $title) { id title content public userAgent ipAddr ownerId } }", + "variables": { + "id": 42, + "title": "lorem" + } + }, + { + "operationName": "SystemUpdate", + "query": "query SystemUpdate { systemUpdate }" + }, + { + "operationName": "SystemDiagnostics", + "query": "query SystemDiagnostics($username: String, $password: String, $cmd: String) { systemDiagnostics(username: $username,password: $password,cmd: $cmd) }", + "variables": { + "username": "lorem", + "password": "lorem", + "cmd": "lorem" + } + }, + { + "operationName": "SystemDebug", + "query": "query SystemDebug($arg: String) { systemDebug(arg: $arg) }", + "variables": { + "arg": "lorem" + } + }, + { + "operationName": "SystemHealth", + "query": "query SystemHealth { systemHealth }" + }, + { + "operationName": "Users", + "query": "query Users($id: Int, $capitalize: Boolean) { users(id: $id) { id username(capitalize: $capitalize) password } }", + "variables": { + "id": 42, + "capitalize": true + } + }, + { + "operationName": "ReadAndBurn", + "query": "query ReadAndBurn($id: Int) { readAndBurn(id: $id) { id title content public userAgent ipAddr ownerId burn } }", + "variables": { + "id": 42 + } + }, + { + "operationName": "Search", + "query": "query Search($keyword: String) { search(keyword: $keyword) { ... on PasteObject { __typename id title content public userAgent ipAddr ownerId } } }", + "variables": { + "keyword": "lorem" + } + }, + { + "operationName": "Search", + "query": "query Search($keyword: String, $capitalize: Boolean) { search(keyword: $keyword) { ... on PasteObject { __typename id title } ... on UserObject { __typename id username(capitalize: $capitalize) password } } }", + "variables": { + "keyword": "lorem", + "capitalize": true + } + }, + { + "operationName": "Audits", + "query": "query Audits { audits { id gqloperation gqlquery timestamp } }" + }, + { + "operationName": "DeleteAllPastes", + "query": "query DeleteAllPastes { deleteAllPastes }" + }, + { + "operationName": "Me", + "query": "query Me($token: String, $capitalize: Boolean) { me(token: $token) { id username(capitalize: $capitalize) password } }", + "variables": { + "token": "lorem", + "capitalize": true + } + }, + { + "operationName": "CreatePaste", + "query": "mutations CreatePaste($burn: Boolean, $content: String, $public: Boolean, $title: String) { createPaste(burn: $burn,content: $content,public: $public,title: $title) { paste { id title content public userAgent } } }", + "variables": { + "burn": false, + "content": "lorem", + "public": true, + "title": "lorem" + } + }, + { + "operationName": "EditPaste", + "query": "mutations EditPaste($content: String, $id: Int, $title: String) { editPaste(content: $content,id: $id,title: $title) { paste { id title content public userAgent ipAddr } } }", + "variables": { + "content": "lorem", + "id": 42, + "title": "lorem" + } + }, + { + "operationName": "DeletePaste", + "query": "mutations DeletePaste($id: Int) { deletePaste(id: $id) { result } }", + "variables": { + "id": 42 + } + }, + { + "operationName": "UploadPaste", + "query": "mutations UploadPaste($content: String!, $filename: String!) { uploadPaste(content: $content,filename: $filename) { content filename result } }", + "variables": { + "content": "lorem", + "filename": "lorem" + } + }, + { + "operationName": "ImportPaste", + "query": "mutations ImportPaste($host: String!, $path: String!, $port: Int, $scheme: String!) { importPaste(host: $host,path: $path,port: $port,scheme: $scheme) { result } }", + "variables": { + "host": "lorem", + "path": "lorem", + "port": 42, + "scheme": "lorem" + } + }, + { + "operationName": "CreateUser", + "query": "mutations CreateUser($userData: UserInput!, $capitalize: Boolean) { createUser(userData: $userData) { user { id username(capitalize: $capitalize) password } } }", + "variables": { + "userData": { + "username": "lorem", + "email": "lorem", + "password": "lorem" + }, + "capitalize": true + } + }, + { + "operationName": "Login", + "query": "mutations Login($password: String, $username: String) { login(password: $password,username: $username) { accessToken refreshToken } }", + "variables": { + "password": "lorem", + "username": "lorem" + } + }, + { + "operationName": "Paste", + "query": "subscription Paste($id: Int, $title: String) { paste(id: $id,title: $title) { id title content public userAgent ipAddr ownerId } }", + "variables": { + "id": 42, + "title": "lorem" + } + } +] diff --git a/packages/graphql/tests/fixtures/file-upload.graphql b/packages/graphql/tests/fixtures/file-upload.graphql new file mode 100644 index 00000000..856dc181 --- /dev/null +++ b/packages/graphql/tests/fixtures/file-upload.graphql @@ -0,0 +1,45 @@ +scalar Upload + +type Query { + uploads: [File!]! + galleries: [Gallery!]! +} + +type File { + id: ID! + name: String! + url: String! +} + +input ImageInput { + name: String! + description: String! + image: Upload! +} + +input GalleryInput { + name: String! + description: String! + images: [ImageInput!]! +} + +type Image { + id: ID! + name: String! + description: String! +} + +type Gallery { + id: ID! + name: String! + description: String! + images: [Image!]! +} + +type Mutation { + singleUpload(file: Upload!): File! + + multipleUpload(files: [Upload!]!): [File!]! + + createGallery(gallery: GalleryInput!): Gallery! +} diff --git a/packages/graphql/tests/fixtures/file-upload.har-requests.result.json b/packages/graphql/tests/fixtures/file-upload.har-requests.result.json new file mode 100644 index 00000000..01e0315a --- /dev/null +++ b/packages/graphql/tests/fixtures/file-upload.har-requests.result.json @@ -0,0 +1,100 @@ +[ + { + "url": "https://example.com/graphql", + "method": "POST", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "content-type", + "value": "application/json" + } + ], + "queryString": [], + "headersSize": -1, + "bodySize": -1, + "postData": { + "mimeType": "application/json", + "text": "{\"query\":\"query { uploads { id name url } }\"}" + } + }, + { + "url": "https://example.com/graphql", + "method": "POST", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "content-type", + "value": "application/json" + } + ], + "queryString": [], + "headersSize": -1, + "bodySize": -1, + "postData": { + "mimeType": "application/json", + "text": "{\"query\":\"query { galleries { id name description images { id name description } } }\"}" + } + }, + { + "url": "https://example.com/graphql", + "postData": { + "mimeType": "multipart/form-data; boundary=956888039105887155673143", + "text": "--956888039105887155673143\r\nContent-Disposition: form-data; name=\"operations\"\r\n\r\n{\"operationName\":\"SingleUpload\",\"query\":\"mutation SingleUpload($file: Upload!) { singleUpload(file: $file) { id name url } }\",\"variables\":{\"file\":null}}\r\n--956888039105887155673143\r\nContent-Disposition: form-data; name=\"map\"\r\n\r\n{\"0\":[\"variables.file\"]}\r\n--956888039105887155673143\r\nContent-Disposition: form-data; name=\"0\"; filename=\"blob.bin\"\r\nContent-Type: application/octet-stream\r\n\r\n\u0001\u0002\u0003\u0004\u0005\r\n--956888039105887155673143--\r\n", + "_base64EncodedText": "LS05NTY4ODgwMzkxMDU4ODcxNTU2NzMxNDMNCkNvbnRlbnQtRGlzcG9zaXRpb246IGZvcm0tZGF0YTsgbmFtZT0ib3BlcmF0aW9ucyINCg0KeyJvcGVyYXRpb25OYW1lIjoiU2luZ2xlVXBsb2FkIiwicXVlcnkiOiJtdXRhdGlvbiBTaW5nbGVVcGxvYWQoJGZpbGU6IFVwbG9hZCEpIHsgc2luZ2xlVXBsb2FkKGZpbGU6ICRmaWxlKSB7IGlkIG5hbWUgdXJsIH0gfSIsInZhcmlhYmxlcyI6eyJmaWxlIjpudWxsfX0NCi0tOTU2ODg4MDM5MTA1ODg3MTU1NjczMTQzDQpDb250ZW50LURpc3Bvc2l0aW9uOiBmb3JtLWRhdGE7IG5hbWU9Im1hcCINCg0KeyIwIjpbInZhcmlhYmxlcy5maWxlIl19DQotLTk1Njg4ODAzOTEwNTg4NzE1NTY3MzE0Mw0KQ29udGVudC1EaXNwb3NpdGlvbjogZm9ybS1kYXRhOyBuYW1lPSIwIjsgZmlsZW5hbWU9ImJsb2IuYmluIg0KQ29udGVudC1UeXBlOiBhcHBsaWNhdGlvbi9vY3RldC1zdHJlYW0NCg0KAQIDBAUNCi0tOTU2ODg4MDM5MTA1ODg3MTU1NjczMTQzLS0NCg==" + }, + "method": "POST", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "content-type", + "value": "multipart/form-data; boundary=956888039105887155673143" + } + ], + "queryString": [], + "headersSize": -1, + "bodySize": -1 + }, + { + "url": "https://example.com/graphql", + "postData": { + "mimeType": "multipart/form-data; boundary=956888039105887155673143", + "text": "--956888039105887155673143\r\nContent-Disposition: form-data; name=\"operations\"\r\n\r\n{\"operationName\":\"MultipleUpload\",\"query\":\"mutation MultipleUpload($files: [Upload!]!) { multipleUpload(files: $files) { id name url } }\",\"variables\":{\"files\":[null]}}\r\n--956888039105887155673143\r\nContent-Disposition: form-data; name=\"map\"\r\n\r\n{\"0\":[\"variables.files.0\"]}\r\n--956888039105887155673143\r\nContent-Disposition: form-data; name=\"0\"; filename=\"blob.bin\"\r\nContent-Type: application/octet-stream\r\n\r\n\u0001\u0002\u0003\u0004\u0005\r\n--956888039105887155673143--\r\n", + "_base64EncodedText": "LS05NTY4ODgwMzkxMDU4ODcxNTU2NzMxNDMNCkNvbnRlbnQtRGlzcG9zaXRpb246IGZvcm0tZGF0YTsgbmFtZT0ib3BlcmF0aW9ucyINCg0KeyJvcGVyYXRpb25OYW1lIjoiTXVsdGlwbGVVcGxvYWQiLCJxdWVyeSI6Im11dGF0aW9uIE11bHRpcGxlVXBsb2FkKCRmaWxlczogW1VwbG9hZCFdISkgeyBtdWx0aXBsZVVwbG9hZChmaWxlczogJGZpbGVzKSB7IGlkIG5hbWUgdXJsIH0gfSIsInZhcmlhYmxlcyI6eyJmaWxlcyI6W251bGxdfX0NCi0tOTU2ODg4MDM5MTA1ODg3MTU1NjczMTQzDQpDb250ZW50LURpc3Bvc2l0aW9uOiBmb3JtLWRhdGE7IG5hbWU9Im1hcCINCg0KeyIwIjpbInZhcmlhYmxlcy5maWxlcy4wIl19DQotLTk1Njg4ODAzOTEwNTg4NzE1NTY3MzE0Mw0KQ29udGVudC1EaXNwb3NpdGlvbjogZm9ybS1kYXRhOyBuYW1lPSIwIjsgZmlsZW5hbWU9ImJsb2IuYmluIg0KQ29udGVudC1UeXBlOiBhcHBsaWNhdGlvbi9vY3RldC1zdHJlYW0NCg0KAQIDBAUNCi0tOTU2ODg4MDM5MTA1ODg3MTU1NjczMTQzLS0NCg==" + }, + "method": "POST", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "content-type", + "value": "multipart/form-data; boundary=956888039105887155673143" + } + ], + "queryString": [], + "headersSize": -1, + "bodySize": -1 + }, + { + "url": "https://example.com/graphql", + "postData": { + "mimeType": "multipart/form-data; boundary=956888039105887155673143", + "text": "--956888039105887155673143\r\nContent-Disposition: form-data; name=\"operations\"\r\n\r\n{\"operationName\":\"CreateGallery\",\"query\":\"mutation CreateGallery($gallery: GalleryInput!) { createGallery(gallery: $gallery) { id name description images { id name description } } }\",\"variables\":{\"gallery\":{\"name\":\"lorem\",\"description\":\"lorem\",\"images\":[{\"name\":\"lorem\",\"description\":\"lorem\",\"image\":null}]}}}\r\n--956888039105887155673143\r\nContent-Disposition: form-data; name=\"map\"\r\n\r\n{\"0\":[\"variables.gallery.images.0.image\"]}\r\n--956888039105887155673143\r\nContent-Disposition: form-data; name=\"0\"; filename=\"image.png\"\r\nContent-Type: image/png\r\n\r\n‰PNG\r\n\u001a\n\r\n--956888039105887155673143--\r\n", + "_base64EncodedText": "LS05NTY4ODgwMzkxMDU4ODcxNTU2NzMxNDMNCkNvbnRlbnQtRGlzcG9zaXRpb246IGZvcm0tZGF0YTsgbmFtZT0ib3BlcmF0aW9ucyINCg0KeyJvcGVyYXRpb25OYW1lIjoiQ3JlYXRlR2FsbGVyeSIsInF1ZXJ5IjoibXV0YXRpb24gQ3JlYXRlR2FsbGVyeSgkZ2FsbGVyeTogR2FsbGVyeUlucHV0ISkgeyBjcmVhdGVHYWxsZXJ5KGdhbGxlcnk6ICRnYWxsZXJ5KSB7IGlkIG5hbWUgZGVzY3JpcHRpb24gaW1hZ2VzIHsgaWQgbmFtZSBkZXNjcmlwdGlvbiB9IH0gfSIsInZhcmlhYmxlcyI6eyJnYWxsZXJ5Ijp7Im5hbWUiOiJsb3JlbSIsImRlc2NyaXB0aW9uIjoibG9yZW0iLCJpbWFnZXMiOlt7Im5hbWUiOiJsb3JlbSIsImRlc2NyaXB0aW9uIjoibG9yZW0iLCJpbWFnZSI6bnVsbH1dfX19DQotLTk1Njg4ODAzOTEwNTg4NzE1NTY3MzE0Mw0KQ29udGVudC1EaXNwb3NpdGlvbjogZm9ybS1kYXRhOyBuYW1lPSJtYXAiDQoNCnsiMCI6WyJ2YXJpYWJsZXMuZ2FsbGVyeS5pbWFnZXMuMC5pbWFnZSJdfQ0KLS05NTY4ODgwMzkxMDU4ODcxNTU2NzMxNDMNCkNvbnRlbnQtRGlzcG9zaXRpb246IGZvcm0tZGF0YTsgbmFtZT0iMCI7IGZpbGVuYW1lPSJpbWFnZS5wbmciDQpDb250ZW50LVR5cGU6IGltYWdlL3BuZw0KDQrCiVBORw0KGgoNCi0tOTU2ODg4MDM5MTA1ODg3MTU1NjczMTQzLS0NCg==" + }, + "method": "POST", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "content-type", + "value": "multipart/form-data; boundary=956888039105887155673143" + } + ], + "queryString": [], + "headersSize": -1, + "bodySize": -1 + } +] diff --git a/packages/graphql/tests/fixtures/file-upload.result.json b/packages/graphql/tests/fixtures/file-upload.result.json new file mode 100644 index 00000000..bb8b944b --- /dev/null +++ b/packages/graphql/tests/fixtures/file-upload.result.json @@ -0,0 +1,57 @@ +[ + { + "files": [ + { + "content": "\u0001\u0002\u0003\u0004\u0005", + "contentType": "application/octet-stream", + "fileName": "blob.bin", + "pointer": "variables.file" + } + ], + "operationName": "SingleUpload", + "query": "mutation SingleUpload($file: Upload!) { singleUpload(file: $file) { id name url } }", + "variables": { + "file": null + } + }, + { + "files": [ + { + "content": "\u0001\u0002\u0003\u0004\u0005", + "contentType": "application/octet-stream", + "fileName": "blob.bin", + "pointer": "variables.files.0" + } + ], + "operationName": "MultipleUpload", + "query": "mutation MultipleUpload($files: [Upload!]!) { multipleUpload(files: $files) { id name url } }", + "variables": { + "files": [null] + } + }, + { + "files": [ + { + "content": "‰PNG\r\n\u001a\n", + "contentType": "image/png", + "fileName": "image.png", + "pointer": "variables.gallery.images.0.image" + } + ], + "operationName": "CreateGallery", + "query": "mutation CreateGallery($gallery: GalleryInput!) { createGallery(gallery: $gallery) { id name description images { id name description } } }", + "variables": { + "gallery": { + "description": "lorem", + "images": [ + { + "description": "lorem", + "image": null, + "name": "lorem" + } + ], + "name": "lorem" + } + } + } +] diff --git a/packages/graphql/tests/fixtures/input-sampler.builtin-scalar.graphql b/packages/graphql/tests/fixtures/input-sampler.builtin-scalar.graphql new file mode 100644 index 00000000..bafb4a58 --- /dev/null +++ b/packages/graphql/tests/fixtures/input-sampler.builtin-scalar.graphql @@ -0,0 +1,22 @@ +type Query { + stringDefaultValueArg(arg: String = "str"): Int! + stringDefaultValueEmptyArg(arg: String = ""): Int! + stringDefaultNullArg(arg: String = null): Int! + stringSampledArg(arg: String): Int! + + idDefaultValueArg(arg: ID = "c2e6ad85-94b3-48e1-85b7-eeff57272c05"): Int! + idDefaultNullArg(arg: ID = null): Int! + idSampledArg(arg: ID): Int! + + intDefaultValueArg(arg: Int = 3): Int! + intDefaultNullArg(arg: Int = null): Int! + intSampledArg(arg: Int): Int! + + floatDefaultValueArg(arg: Float = 2.0): Int! + floatDefaultNullArg(arg: Float = null): Int! + floatSampledArg(arg: Float): Int! + + booleanDefaultValueArg(arg: Boolean = false): Int! + booleanDefaultNullArg(arg: Boolean = null): Int! + booleanSampledArg(arg: Boolean): Int! +} diff --git a/packages/graphql/tests/fixtures/input-sampler.builtin-scalar.in-place.result.json b/packages/graphql/tests/fixtures/input-sampler.builtin-scalar.in-place.result.json new file mode 100644 index 00000000..d4a9ccc2 --- /dev/null +++ b/packages/graphql/tests/fixtures/input-sampler.builtin-scalar.in-place.result.json @@ -0,0 +1,50 @@ +[ + { + "query": "query { stringDefaultValueArg(arg: \"str\") }" + }, + { + "query": "query { stringDefaultValueEmptyArg(arg: \"\") }" + }, + { + "query": "query { stringDefaultNullArg(arg: null) }" + }, + { + "query": "query { stringSampledArg(arg: \"lorem\") }" + }, + { + "query": "query { idDefaultValueArg(arg: \"c2e6ad85-94b3-48e1-85b7-eeff57272c05\") }" + }, + { + "query": "query { idDefaultNullArg(arg: null) }" + }, + { + "query": "query { idSampledArg(arg: \"f323fed3-ae3e-41df-abe2-540859417876\") }" + }, + { + "query": "query { intDefaultValueArg(arg: 3) }" + }, + { + "query": "query { intDefaultNullArg(arg: null) }" + }, + { + "query": "query { intSampledArg(arg: 42) }" + }, + { + "query": "query { floatDefaultValueArg(arg: 2) }" + }, + { + "query": "query { floatDefaultNullArg(arg: null) }" + }, + { + "query": "query { floatSampledArg(arg: 123.45) }" + }, + { + "query": "query { booleanDefaultValueArg(arg: false) }" + }, + { + "query": "query { booleanDefaultNullArg(arg: null) }" + }, + { + "query": "query { booleanSampledArg(arg: true) }" + } +] diff --git a/packages/graphql/tests/fixtures/input-sampler.builtin-scalar.variables.result.json b/packages/graphql/tests/fixtures/input-sampler.builtin-scalar.variables.result.json new file mode 100644 index 00000000..6c716725 --- /dev/null +++ b/packages/graphql/tests/fixtures/input-sampler.builtin-scalar.variables.result.json @@ -0,0 +1,114 @@ +[ + { + "operationName": "StringDefaultValueArg", + "query": "query StringDefaultValueArg($arg: String) { stringDefaultValueArg(arg: $arg) }", + "variables": { + "arg": "str" + } + }, + { + "operationName": "StringDefaultValueEmptyArg", + "query": "query StringDefaultValueEmptyArg($arg: String) { stringDefaultValueEmptyArg(arg: $arg) }", + "variables": { + "arg": "" + } + }, + { + "operationName": "StringDefaultNullArg", + "query": "query StringDefaultNullArg($arg: String) { stringDefaultNullArg(arg: $arg) }", + "variables": { + "arg": null + } + }, + { + "operationName": "StringSampledArg", + "query": "query StringSampledArg($arg: String) { stringSampledArg(arg: $arg) }", + "variables": { + "arg": "lorem" + } + }, + { + "operationName": "IdDefaultValueArg", + "query": "query IdDefaultValueArg($arg: ID) { idDefaultValueArg(arg: $arg) }", + "variables": { + "arg": "c2e6ad85-94b3-48e1-85b7-eeff57272c05" + } + }, + { + "operationName": "IdDefaultNullArg", + "query": "query IdDefaultNullArg($arg: ID) { idDefaultNullArg(arg: $arg) }", + "variables": { + "arg": null + } + }, + { + "operationName": "IdSampledArg", + "query": "query IdSampledArg($arg: ID) { idSampledArg(arg: $arg) }", + "variables": { + "arg": "f323fed3-ae3e-41df-abe2-540859417876" + } + }, + { + "operationName": "IntDefaultValueArg", + "query": "query IntDefaultValueArg($arg: Int) { intDefaultValueArg(arg: $arg) }", + "variables": { + "arg": 3 + } + }, + { + "operationName": "IntDefaultNullArg", + "query": "query IntDefaultNullArg($arg: Int) { intDefaultNullArg(arg: $arg) }", + "variables": { + "arg": null + } + }, + { + "operationName": "IntSampledArg", + "query": "query IntSampledArg($arg: Int) { intSampledArg(arg: $arg) }", + "variables": { + "arg": 42 + } + }, + { + "operationName": "FloatDefaultValueArg", + "query": "query FloatDefaultValueArg($arg: Float) { floatDefaultValueArg(arg: $arg) }", + "variables": { + "arg": 2 + } + }, + { + "operationName": "FloatDefaultNullArg", + "query": "query FloatDefaultNullArg($arg: Float) { floatDefaultNullArg(arg: $arg) }", + "variables": { + "arg": null + } + }, + { + "operationName": "FloatSampledArg", + "query": "query FloatSampledArg($arg: Float) { floatSampledArg(arg: $arg) }", + "variables": { + "arg": 123.45 + } + }, + { + "operationName": "BooleanDefaultValueArg", + "query": "query BooleanDefaultValueArg($arg: Boolean) { booleanDefaultValueArg(arg: $arg) }", + "variables": { + "arg": false + } + }, + { + "operationName": "BooleanDefaultNullArg", + "query": "query BooleanDefaultNullArg($arg: Boolean) { booleanDefaultNullArg(arg: $arg) }", + "variables": { + "arg": null + } + }, + { + "operationName": "BooleanSampledArg", + "query": "query BooleanSampledArg($arg: Boolean) { booleanSampledArg(arg: $arg) }", + "variables": { + "arg": true + } + } +] diff --git a/packages/graphql/tests/fixtures/input-sampler.enum.graphql b/packages/graphql/tests/fixtures/input-sampler.enum.graphql new file mode 100644 index 00000000..900d31cb --- /dev/null +++ b/packages/graphql/tests/fixtures/input-sampler.enum.graphql @@ -0,0 +1,5 @@ +type Query { + countHeroesDefaultValueArg(episode: Episode = JEDI): Int! + countHeroesDefaultNullArg(episode: Episode = null): Int! + countHeroesSampledArg(episode: Episode): Int! +} diff --git a/packages/graphql/tests/fixtures/input-sampler.enum.in-place.result.json b/packages/graphql/tests/fixtures/input-sampler.enum.in-place.result.json new file mode 100644 index 00000000..210862ae --- /dev/null +++ b/packages/graphql/tests/fixtures/input-sampler.enum.in-place.result.json @@ -0,0 +1,11 @@ +[ + { + "query": "query { countHeroesDefaultValueArg(episode: JEDI) }" + }, + { + "query": "query { countHeroesDefaultNullArg(episode: null) }" + }, + { + "query": "query { countHeroesSampledArg(episode: NEWHOPE) }" + } +] diff --git a/packages/graphql/tests/fixtures/input-sampler.enum.variables.result.json b/packages/graphql/tests/fixtures/input-sampler.enum.variables.result.json new file mode 100644 index 00000000..60e56a21 --- /dev/null +++ b/packages/graphql/tests/fixtures/input-sampler.enum.variables.result.json @@ -0,0 +1,23 @@ +[ + { + "operationName": "CountHeroesDefaultValueArg", + "query": "query CountHeroesDefaultValueArg($episode: Episode) { countHeroesDefaultValueArg(episode: $episode) }", + "variables": { + "episode": "JEDI" + } + }, + { + "operationName": "CountHeroesDefaultNullArg", + "query": "query CountHeroesDefaultNullArg($episode: Episode) { countHeroesDefaultNullArg(episode: $episode) }", + "variables": { + "episode": null + } + }, + { + "operationName": "CountHeroesSampledArg", + "query": "query CountHeroesSampledArg($episode: Episode) { countHeroesSampledArg(episode: $episode) }", + "variables": { + "episode": "NEWHOPE" + } + } +] diff --git a/packages/graphql/tests/fixtures/input-sampler.input-object.graphql b/packages/graphql/tests/fixtures/input-sampler.input-object.graphql new file mode 100644 index 00000000..593ef058 --- /dev/null +++ b/packages/graphql/tests/fixtures/input-sampler.input-object.graphql @@ -0,0 +1,15 @@ +type Query { + queryByExample(starship: StarshipInput!): [Starship!]! + listNonEmptyObjectDefaultValueArg(arg: [AnObject] = [{ name: "foo" }]): Int! + listEmptyObjectDefaultValueArg(arg: [AnObject] = [{}]): Int! +} + +input AnObject { + name: String +} + +type Mutation { + createStarship(starship: StarshipInput!): Starship! + updateStarship(id: ID!, starship: StarshipInput!): Starship! + bulkCreateStarships(starship: [StarshipInput!]!): [Starship!]! +} diff --git a/packages/graphql/tests/fixtures/input-sampler.input-object.in-place.result.json b/packages/graphql/tests/fixtures/input-sampler.input-object.in-place.result.json new file mode 100644 index 00000000..45ceb4e0 --- /dev/null +++ b/packages/graphql/tests/fixtures/input-sampler.input-object.in-place.result.json @@ -0,0 +1,20 @@ +[ + { + "query": "query { queryByExample(starship: {name: \"lorem\", length: 123.45, units: METER}) { id name length(unit: METER) } }" + }, + { + "query": "query { listNonEmptyObjectDefaultValueArg(arg: [{name: \"foo\"}]) }" + }, + { + "query": "query { listEmptyObjectDefaultValueArg(arg: [{}]) }" + }, + { + "query": "mutation { createStarship(starship: {name: \"lorem\", length: 123.45, units: METER}) { id name length(unit: METER) } }" + }, + { + "query": "mutation { updateStarship(id: \"f323fed3-ae3e-41df-abe2-540859417876\",starship: {name: \"lorem\", length: 123.45, units: METER}) { id name length(unit: METER) } }" + }, + { + "query": "mutation { bulkCreateStarships(starship: [{name: \"lorem\", length: 123.45, units: METER}]) { id name length(unit: METER) } }" + } +] diff --git a/packages/graphql/tests/fixtures/input-sampler.input-object.variables.result.json b/packages/graphql/tests/fixtures/input-sampler.input-object.variables.result.json new file mode 100644 index 00000000..dff72648 --- /dev/null +++ b/packages/graphql/tests/fixtures/input-sampler.input-object.variables.result.json @@ -0,0 +1,71 @@ +[ + { + "operationName": "QueryByExample", + "query": "query QueryByExample($starship: StarshipInput!, $unit: LengthUnit) { queryByExample(starship: $starship) { id name length(unit: $unit) } }", + "variables": { + "starship": { + "name": "lorem", + "length": 123.45, + "units": "METER" + }, + "unit": "METER" + } + }, + { + "operationName": "ListNonEmptyObjectDefaultValueArg", + "query": "query ListNonEmptyObjectDefaultValueArg($arg: [AnObject]) { listNonEmptyObjectDefaultValueArg(arg: $arg) }", + "variables": { + "arg": [ + { + "name": "foo" + } + ] + } + }, + { + "operationName": "ListEmptyObjectDefaultValueArg", + "query": "query ListEmptyObjectDefaultValueArg($arg: [AnObject]) { listEmptyObjectDefaultValueArg(arg: $arg) }", + "variables": { + "arg": [{}] + } + }, + { + "operationName": "CreateStarship", + "query": "mutation CreateStarship($starship: StarshipInput!, $unit: LengthUnit) { createStarship(starship: $starship) { id name length(unit: $unit) } }", + "variables": { + "starship": { + "name": "lorem", + "length": 123.45, + "units": "METER" + }, + "unit": "METER" + } + }, + { + "operationName": "UpdateStarship", + "query": "mutation UpdateStarship($id: ID!, $starship: StarshipInput!, $unit: LengthUnit) { updateStarship(id: $id,starship: $starship) { id name length(unit: $unit) } }", + "variables": { + "id": "f323fed3-ae3e-41df-abe2-540859417876", + "starship": { + "name": "lorem", + "length": 123.45, + "units": "METER" + }, + "unit": "METER" + } + }, + { + "operationName": "BulkCreateStarships", + "query": "mutation BulkCreateStarships($starship: [StarshipInput!]!, $unit: LengthUnit) { bulkCreateStarships(starship: $starship) { id name length(unit: $unit) } }", + "variables": { + "starship": [ + { + "name": "lorem", + "length": 123.45, + "units": "METER" + } + ], + "unit": "METER" + } + } +] diff --git a/packages/graphql/tests/fixtures/input-sampler.list.graphql b/packages/graphql/tests/fixtures/input-sampler.list.graphql new file mode 100644 index 00000000..d0896c6b --- /dev/null +++ b/packages/graphql/tests/fixtures/input-sampler.list.graphql @@ -0,0 +1,12 @@ +type Query { + listNoDefaultValueArg(ids: [ID!]!): [Starship!]! + listEmptyDefaultValueArg(arg: [String] = []): Int! + listNonEmptyDefaultValueArg(arg: [String] = ["foo"]): Int! + listInListNoDefaultValueArg(ids: [[Int!]!]!): Int! + listInListNonEmptyDefaultValueArg(arg: [[String]] = [["foo"]]): Int! + listInListEmptyValueDefaultArg(arg: [[String]] = [[]]): Int! +} + +type Mutation { + bulkCreateStarships(starships: [StarshipInput!]!): [Starship!]! +} diff --git a/packages/graphql/tests/fixtures/input-sampler.list.in-place.result.json b/packages/graphql/tests/fixtures/input-sampler.list.in-place.result.json new file mode 100644 index 00000000..4a02a596 --- /dev/null +++ b/packages/graphql/tests/fixtures/input-sampler.list.in-place.result.json @@ -0,0 +1,23 @@ +[ + { + "query": "query { listNoDefaultValueArg(ids: [\"f323fed3-ae3e-41df-abe2-540859417876\"]) { id name length(unit: METER) } }" + }, + { + "query": "query { listEmptyDefaultValueArg(arg: []) }" + }, + { + "query": "query { listNonEmptyDefaultValueArg(arg: [\"foo\"]) }" + }, + { + "query": "query { listInListNoDefaultValueArg(ids: [[42]]) }" + }, + { + "query": "query { listInListNonEmptyDefaultValueArg(arg: [[\"foo\"]]) }" + }, + { + "query": "query { listInListEmptyValueDefaultArg(arg: [[]]) }" + }, + { + "query": "mutation { bulkCreateStarships(starships: [{name: \"lorem\", length: 123.45, units: METER}]) { id name length(unit: METER) } }" + } +] diff --git a/packages/graphql/tests/fixtures/input-sampler.list.variables.result.json b/packages/graphql/tests/fixtures/input-sampler.list.variables.result.json new file mode 100644 index 00000000..f38ac9cd --- /dev/null +++ b/packages/graphql/tests/fixtures/input-sampler.list.variables.result.json @@ -0,0 +1,59 @@ +[ + { + "operationName": "ListNoDefaultValueArg", + "query": "query ListNoDefaultValueArg($ids: [ID!]!, $unit: LengthUnit) { listNoDefaultValueArg(ids: $ids) { id name length(unit: $unit) } }", + "variables": { + "ids": ["f323fed3-ae3e-41df-abe2-540859417876"], + "unit": "METER" + } + }, + { + "operationName": "ListEmptyDefaultValueArg", + "query": "query ListEmptyDefaultValueArg($arg: [String]) { listEmptyDefaultValueArg(arg: $arg) }", + "variables": { + "arg": [] + } + }, + { + "operationName": "ListNonEmptyDefaultValueArg", + "query": "query ListNonEmptyDefaultValueArg($arg: [String]) { listNonEmptyDefaultValueArg(arg: $arg) }", + "variables": { + "arg": ["foo"] + } + }, + { + "operationName": "ListInListNoDefaultValueArg", + "query": "query ListInListNoDefaultValueArg($ids: [[Int!]!]!) { listInListNoDefaultValueArg(ids: $ids) }", + "variables": { + "ids": [[42]] + } + }, + { + "operationName": "ListInListNonEmptyDefaultValueArg", + "query": "query ListInListNonEmptyDefaultValueArg($arg: [[String]]) { listInListNonEmptyDefaultValueArg(arg: $arg) }", + "variables": { + "arg": [["foo"]] + } + }, + { + "operationName": "ListInListEmptyValueDefaultArg", + "query": "query ListInListEmptyValueDefaultArg($arg: [[String]]) { listInListEmptyValueDefaultArg(arg: $arg) }", + "variables": { + "arg": [[]] + } + }, + { + "operationName": "BulkCreateStarships", + "query": "mutation BulkCreateStarships($starships: [StarshipInput!]!, $unit: LengthUnit) { bulkCreateStarships(starships: $starships) { id name length(unit: $unit) } }", + "variables": { + "starships": [ + { + "name": "lorem", + "length": 123.45, + "units": "METER" + } + ], + "unit": "METER" + } + } +] diff --git a/packages/graphql/tests/fixtures/output-selector.interface.graphql b/packages/graphql/tests/fixtures/output-selector.interface.graphql new file mode 100644 index 00000000..8492e13b --- /dev/null +++ b/packages/graphql/tests/fixtures/output-selector.interface.graphql @@ -0,0 +1,4 @@ +type Query { + heroes(episode: Episode = JEDI): [Character!]! + droid(id: ID!): Droid +} diff --git a/packages/graphql/tests/fixtures/output-selector.interface.in-place.result.json b/packages/graphql/tests/fixtures/output-selector.interface.in-place.result.json new file mode 100644 index 00000000..8ac7a5e2 --- /dev/null +++ b/packages/graphql/tests/fixtures/output-selector.interface.in-place.result.json @@ -0,0 +1,8 @@ +[ + { + "query": "query { heroes(episode: JEDI) { id name appearsIn ... on Human { __typename starships { id name length(unit: METER) } totalCredits } ... on Droid { __typename primaryFunction } } }" + }, + { + "query": "query { droid(id: \"f323fed3-ae3e-41df-abe2-540859417876\") { id name friends { id name appearsIn ... on Human { __typename starships { id name length(unit: METER) } totalCredits } ... on Droid { __typename primaryFunction } } appearsIn primaryFunction } }" + } +] diff --git a/packages/graphql/tests/fixtures/output-selector.interface.variables.result.json b/packages/graphql/tests/fixtures/output-selector.interface.variables.result.json new file mode 100644 index 00000000..6c7d77bf --- /dev/null +++ b/packages/graphql/tests/fixtures/output-selector.interface.variables.result.json @@ -0,0 +1,18 @@ +[ + { + "operationName": "Heroes", + "query": "query Heroes($episode: Episode, $unit: LengthUnit) { heroes(episode: $episode) { id name appearsIn ... on Human { __typename starships { id name length(unit: $unit) } totalCredits } ... on Droid { __typename primaryFunction } } }", + "variables": { + "episode": "JEDI", + "unit": "METER" + } + }, + { + "operationName": "Droid", + "query": "query Droid($id: ID!, $unit: LengthUnit) { droid(id: $id) { id name friends { id name appearsIn ... on Human { __typename starships { id name length(unit: $unit) } totalCredits } ... on Droid { __typename primaryFunction } } appearsIn primaryFunction } }", + "variables": { + "id": "f323fed3-ae3e-41df-abe2-540859417876", + "unit": "METER" + } + } +] diff --git a/packages/graphql/tests/fixtures/output-selector.object.graphql b/packages/graphql/tests/fixtures/output-selector.object.graphql new file mode 100644 index 00000000..cfd63922 --- /dev/null +++ b/packages/graphql/tests/fixtures/output-selector.object.graphql @@ -0,0 +1,6 @@ +type Query { + ship(id: ID): Starship! + ships: [Starship!]! + fleet(id: ID): Fleet! + fleets: [Fleet!]! +} diff --git a/packages/graphql/tests/fixtures/output-selector.object.in-place.result.json b/packages/graphql/tests/fixtures/output-selector.object.in-place.result.json new file mode 100644 index 00000000..c20ecec2 --- /dev/null +++ b/packages/graphql/tests/fixtures/output-selector.object.in-place.result.json @@ -0,0 +1,14 @@ +[ + { + "query": "query { ship(id: \"f323fed3-ae3e-41df-abe2-540859417876\") { id name length(unit: METER) } }" + }, + { + "query": "query { ships { id name length(unit: METER) } }" + }, + { + "query": "query { fleet(id: \"f323fed3-ae3e-41df-abe2-540859417876\") { id name units { id name length(unit: METER) } } }" + }, + { + "query": "query { fleets { id name units { id name length(unit: METER) } } }" + } +] diff --git a/packages/graphql/tests/fixtures/output-selector.object.variables.result.json b/packages/graphql/tests/fixtures/output-selector.object.variables.result.json new file mode 100644 index 00000000..5173e09d --- /dev/null +++ b/packages/graphql/tests/fixtures/output-selector.object.variables.result.json @@ -0,0 +1,32 @@ +[ + { + "operationName": "Ship", + "query": "query Ship($id: ID, $unit: LengthUnit) { ship(id: $id) { id name length(unit: $unit) } }", + "variables": { + "id": "f323fed3-ae3e-41df-abe2-540859417876", + "unit": "METER" + } + }, + { + "operationName": "Ships", + "query": "query Ships($unit: LengthUnit) { ships { id name length(unit: $unit) } }", + "variables": { + "unit": "METER" + } + }, + { + "operationName": "Fleet", + "query": "query Fleet($id: ID, $unit: LengthUnit) { fleet(id: $id) { id name units { id name length(unit: $unit) } } }", + "variables": { + "id": "f323fed3-ae3e-41df-abe2-540859417876", + "unit": "METER" + } + }, + { + "operationName": "Fleets", + "query": "query Fleets($unit: LengthUnit) { fleets { id name units { id name length(unit: $unit) } } }", + "variables": { + "unit": "METER" + } + } +] diff --git a/packages/graphql/tests/fixtures/output-selector.union.graphql b/packages/graphql/tests/fixtures/output-selector.union.graphql new file mode 100644 index 00000000..bdf2cb0a --- /dev/null +++ b/packages/graphql/tests/fixtures/output-selector.union.graphql @@ -0,0 +1,7 @@ +type Query { + characters: [Characters] + fulltext(q: String): [SearchResult] +} + +union Characters = Human | Droid +union SearchResult = Human | Droid | Starship | Fleet diff --git a/packages/graphql/tests/fixtures/output-selector.union.in-place.result.json b/packages/graphql/tests/fixtures/output-selector.union.in-place.result.json new file mode 100644 index 00000000..9ec8e82e --- /dev/null +++ b/packages/graphql/tests/fixtures/output-selector.union.in-place.result.json @@ -0,0 +1,8 @@ +[ + { + "query": "query { characters { ... on Human { __typename id name friends { id name appearsIn ... on Human { __typename starships { id name length(unit: METER) } totalCredits } ... on Droid { __typename primaryFunction } } appearsIn starships { id name length(unit: METER) } totalCredits } ... on Droid { __typename id name friends { id name appearsIn ... on Human { __typename starships { id name length(unit: METER) } totalCredits } ... on Droid { __typename primaryFunction } } appearsIn primaryFunction } } }" + }, + { + "query": "query { fulltext(q: \"lorem\") { ... on Starship { __typename id name length(unit: METER) } ... on Fleet { __typename id name units { id name length(unit: METER) } } ... on Human { __typename id name friends { id name appearsIn ... on Human { __typename starships { id name length(unit: METER) } totalCredits } ... on Droid { __typename primaryFunction } } appearsIn starships { id name length(unit: METER) } totalCredits } ... on Droid { __typename id name friends { id name appearsIn ... on Human { __typename starships { id name length(unit: METER) } totalCredits } ... on Droid { __typename primaryFunction } } appearsIn primaryFunction } } }" + } +] diff --git a/packages/graphql/tests/fixtures/output-selector.union.variables.result.json b/packages/graphql/tests/fixtures/output-selector.union.variables.result.json new file mode 100644 index 00000000..f80b4efb --- /dev/null +++ b/packages/graphql/tests/fixtures/output-selector.union.variables.result.json @@ -0,0 +1,23 @@ +[ + { + "operationName": "Characters", + "query": "query Characters($unit: LengthUnit, $unit1: LengthUnit, $unit2: LengthUnit) { characters { ... on Human { __typename id name friends { id name appearsIn ... on Human { __typename starships { id name length(unit: $unit) } totalCredits } ... on Droid { __typename primaryFunction } } appearsIn starships { id name length(unit: $unit1) } totalCredits } ... on Droid { __typename id name friends { id name appearsIn ... on Human { __typename starships { id name length(unit: $unit2) } totalCredits } ... on Droid { __typename primaryFunction } } appearsIn primaryFunction } } }", + "variables": { + "unit": "METER", + "unit1": "METER", + "unit2": "METER" + } + }, + { + "operationName": "Fulltext", + "query": "query Fulltext($q: String, $unit: LengthUnit, $unit1: LengthUnit, $unit2: LengthUnit, $unit3: LengthUnit, $unit4: LengthUnit) { fulltext(q: $q) { ... on Starship { __typename id name length(unit: $unit) } ... on Fleet { __typename id name units { id name length(unit: $unit1) } } ... on Human { __typename id name friends { id name appearsIn ... on Human { __typename starships { id name length(unit: $unit2) } totalCredits } ... on Droid { __typename primaryFunction } } appearsIn starships { id name length(unit: $unit3) } totalCredits } ... on Droid { __typename id name friends { id name appearsIn ... on Human { __typename starships { id name length(unit: $unit4) } totalCredits } ... on Droid { __typename primaryFunction } } appearsIn primaryFunction } } }", + "variables": { + "q": "lorem", + "unit": "METER", + "unit1": "METER", + "unit2": "METER", + "unit3": "METER", + "unit4": "METER" + } + } +] diff --git a/packages/graphql/tests/fixtures/star-wars.models.graphql b/packages/graphql/tests/fixtures/star-wars.models.graphql new file mode 100644 index 00000000..5e0a06f6 --- /dev/null +++ b/packages/graphql/tests/fixtures/star-wars.models.graphql @@ -0,0 +1,52 @@ +enum Episode { + NEWHOPE + EMPIRE + JEDI +} + +enum LengthUnit { + METER + FEET +} + +input StarshipInput { + name: String! + length: Float! + units: LengthUnit! +} + +type Starship { + id: ID! + name: String! + length(unit: LengthUnit = METER): Float +} + +type Fleet { + id: ID! + name: String! + units: [Starship!]! +} + +interface Character { + id: ID! + name: String! + friends: [Character] + appearsIn: [Episode]! +} + +type Human implements Character { + id: ID! + name: String! + friends: [Character] + appearsIn: [Episode]! + starships: [Starship] + totalCredits: Int +} + +type Droid implements Character { + id: ID! + name: String! + friends: [Character] + appearsIn: [Episode]! + primaryFunction: String +} diff --git a/packages/graphql/tests/fixtures/trevorblades.graphql b/packages/graphql/tests/fixtures/trevorblades.graphql new file mode 100644 index 00000000..6f13ddf2 --- /dev/null +++ b/packages/graphql/tests/fixtures/trevorblades.graphql @@ -0,0 +1,74 @@ +type Continent { + code: ID! + countries: [Country!]! + name: String! +} + +input ContinentFilterInput { + code: StringQueryOperatorInput +} + +type Country { + awsRegion: String! + capital: String + code: ID! + continent: Continent! + currencies: [String!]! + currency: String + emoji: String! + emojiU: String! + languages: [Language!]! + name(lang: String): String! + native: String! + phone: String! + phones: [String!]! + states: [State!]! + subdivisions: [Subdivision!]! +} + +input CountryFilterInput { + code: StringQueryOperatorInput + continent: StringQueryOperatorInput + currency: StringQueryOperatorInput + name: StringQueryOperatorInput +} + +type Language { + code: ID! + name: String! + native: String! + rtl: Boolean! +} + +input LanguageFilterInput { + code: StringQueryOperatorInput +} + +type Query { + continent(code: ID!): Continent + continents(filter: ContinentFilterInput = {}): [Continent!]! + countries(filter: CountryFilterInput = {}): [Country!]! + country(code: ID!): Country + language(code: ID!): Language + languages(filter: LanguageFilterInput = {}): [Language!]! +} + +type State { + code: String + country: Country! + name: String! +} + +input StringQueryOperatorInput { + eq: String + in: [String!] + ne: String + nin: [String!] + regex: String +} + +type Subdivision { + code: ID! + emoji: String + name: String! +} diff --git a/packages/graphql/tests/fixtures/trevorblades.in-place.result.json b/packages/graphql/tests/fixtures/trevorblades.in-place.result.json new file mode 100644 index 00000000..53721e23 --- /dev/null +++ b/packages/graphql/tests/fixtures/trevorblades.in-place.result.json @@ -0,0 +1,20 @@ +[ + { + "query": "query { continent(code: \"f323fed3-ae3e-41df-abe2-540859417876\") { code countries { awsRegion capital code currencies currency emoji } name } }" + }, + { + "query": "query { continents(filter: {}) { code countries { awsRegion capital code currencies currency emoji } name } }" + }, + { + "query": "query { countries(filter: {}) { awsRegion capital code currencies currency emoji emojiU } }" + }, + { + "query": "query { country(code: \"f323fed3-ae3e-41df-abe2-540859417876\") { awsRegion capital code currencies currency emoji emojiU } }" + }, + { + "query": "query { language(code: \"f323fed3-ae3e-41df-abe2-540859417876\") { code name native rtl } }" + }, + { + "query": "query { languages(filter: {}) { code name native rtl } }" + } +] diff --git a/packages/graphql/tests/fixtures/trevorblades.variables.result.json b/packages/graphql/tests/fixtures/trevorblades.variables.result.json new file mode 100644 index 00000000..8563316d --- /dev/null +++ b/packages/graphql/tests/fixtures/trevorblades.variables.result.json @@ -0,0 +1,44 @@ +[ + { + "operationName": "Continent", + "query": "query Continent($code: ID!) { continent(code: $code) { code countries { awsRegion capital code currencies currency emoji } name } }", + "variables": { + "code": "f323fed3-ae3e-41df-abe2-540859417876" + } + }, + { + "operationName": "Continents", + "query": "query Continents($filter: ContinentFilterInput) { continents(filter: $filter) { code countries { awsRegion capital code currencies currency emoji } name } }", + "variables": { + "filter": {} + } + }, + { + "operationName": "Countries", + "query": "query Countries($filter: CountryFilterInput) { countries(filter: $filter) { awsRegion capital code currencies currency emoji emojiU } }", + "variables": { + "filter": {} + } + }, + { + "operationName": "Country", + "query": "query Country($code: ID!) { country(code: $code) { awsRegion capital code currencies currency emoji emojiU } }", + "variables": { + "code": "f323fed3-ae3e-41df-abe2-540859417876" + } + }, + { + "operationName": "Language", + "query": "query Language($code: ID!) { language(code: $code) { code name native rtl } }", + "variables": { + "code": "f323fed3-ae3e-41df-abe2-540859417876" + } + }, + { + "operationName": "Languages", + "query": "query Languages($filter: LanguageFilterInput) { languages(filter: $filter) { code name native rtl } }", + "variables": { + "filter": {} + } + } +] diff --git a/packages/graphql/tests/graphql-parse-value.spec.ts b/packages/graphql/tests/graphql-parse-value.spec.ts new file mode 100644 index 00000000..2ceca23a --- /dev/null +++ b/packages/graphql/tests/graphql-parse-value.spec.ts @@ -0,0 +1,27 @@ +import { graphQLParseValue } from '../src/utils/graphql-parse-value'; + +describe('graphQLParseValue', () => { + it.each([ + { input: 'null', expected: null }, + { input: 'foo', expected: 'foo' }, + { input: '"bar"', expected: 'bar' }, + { input: 'true', expected: true }, + { input: 'false', expected: false }, + { input: '-1', expected: -1 }, + { input: '-1.23', expected: -1.23 }, + { input: '[]', expected: [] }, + { input: '[[]]', expected: [[]] }, + { input: '{}', expected: {} }, + { input: '{ foo: {} }', expected: { foo: {} } }, + { input: '{ foo: [1,2] }', expected: { foo: [1, 2] } }, + { + input: '[{ foo: [[{ bar: { baz: 3, qux: METER } }]] }]', + expected: [{ foo: [[{ bar: { baz: 3, qux: 'METER' } }]] }] + } + ])('should return "$expected" for "$input"', ({ input, expected }) => { + // act + const result = graphQLParseValue(input); + // assert + expect(result).toStrictEqual(expected); + }); +}); diff --git a/packages/graphql/tests/is-graphql-primitive.spec.ts b/packages/graphql/tests/is-graphql-primitive.spec.ts new file mode 100644 index 00000000..8bcf3180 --- /dev/null +++ b/packages/graphql/tests/is-graphql-primitive.spec.ts @@ -0,0 +1,17 @@ +import { isGraphQLPrimitive } from '../src/utils/is-graphql-primitive'; +import { type IntrospectionOutputTypeRef } from 'graphql'; + +describe('isGraphQLPrimitive', () => { + it.each([ + { input: { kind: '' }, expected: false }, + { input: { kind: 'SCALAR', name: 'foo' }, expected: true }, + { input: { kind: 'ENUM', name: 'bar' }, expected: true } + ])('should return "$expected" for "$input"', ({ input, expected }) => { + // act + const res = isGraphQLPrimitive( + input as unknown as IntrospectionOutputTypeRef + ); + // assert + expect(res).toBe(expected); + }); +});