diff --git a/packages/insomnia-smoke-test/fixtures/graphql.yaml b/packages/insomnia-smoke-test/fixtures/graphql.yaml index b32b8970d4..b36bd7f5a7 100644 --- a/packages/insomnia-smoke-test/fixtures/graphql.yaml +++ b/packages/insomnia-smoke-test/fixtures/graphql.yaml @@ -29,6 +29,58 @@ resources: settingRebuildPath: true settingFollowRedirects: global _type: request + - _id: req_63e5c8a8d1674bb39ba6df1d0eb452a1 + parentId: wrk_d8bfe72fd18b42daa55d2ccc08c9eecb + modified: 1655289849596 + created: 1655289826760 + url: localhost:4010/graphql + name: GraphQL request with number + description: "" + method: POST + body: + mimeType: application/graphql + text: '{"query": "query($inputVar: Int) { echoNum(intVar: $inputVar)}", "variables": "{ \"inputVar\": {{ _.intVar }} }"}' + parameters: [] + headers: + - name: Content-Type + value: application/json + id: pair_91da32f74847489ab06da669db0b425f + authentication: {} + metaSortKey: -1655289826761 + isPrivate: false + settingStoreCookies: true + settingSendCookies: true + settingDisableRenderRequestBody: false + settingEncodeUrl: true + settingRebuildPath: true + settingFollowRedirects: global + _type: request + - _id: req_63e5c8a8d1674bb39ba6df1d0eb452a2 + parentId: wrk_d8bfe72fd18b42daa55d2ccc08c9eecb + modified: 1655289849596 + created: 1655289826760 + url: localhost:4010/graphql + name: GraphQL request with variables + description: "" + method: POST + body: + mimeType: application/graphql + text: '{"query":"query($vars: VarsInput) {\n\techoVars(vars: $vars) {\n\t\tstringVar \n\t\tintVar\n\t}\n}", "variables":{"vars":{"stringVar":"3","intVar": 3}} }' + parameters: [] + headers: + - name: Content-Type + value: application/json + id: pair_91da32f74847489ab06da669db0b425f + authentication: {} + metaSortKey: -1655289826761 + isPrivate: false + settingStoreCookies: true + settingSendCookies: true + settingDisableRenderRequestBody: false + settingEncodeUrl: true + settingRebuildPath: true + settingFollowRedirects: global + _type: request - _id: wrk_d8bfe72fd18b42daa55d2ccc08c9eecb parentId: null modified: 1655289816535 @@ -42,7 +94,8 @@ resources: modified: 1655289816558 created: 1655289816558 name: Base Environment - data: {} + data: + intVar: 3 dataPropertyOrder: null color: null isPrivate: false diff --git a/packages/insomnia-smoke-test/server/graphql.ts b/packages/insomnia-smoke-test/server/graphql.ts index e3cc87f756..7137ebe900 100644 --- a/packages/insomnia-smoke-test/server/graphql.ts +++ b/packages/insomnia-smoke-test/server/graphql.ts @@ -1,4 +1,20 @@ -import { GraphQLEnumType, GraphQLObjectType, GraphQLSchema, GraphQLString } from 'graphql'; +import { GraphQLEnumType, GraphQLInputObjectType, GraphQLInt, GraphQLObjectType, GraphQLSchema, GraphQLString } from 'graphql'; + +const TypeVars = new GraphQLObjectType({ + name: 'Vars', + fields: () => ({ + stringVar: { type: GraphQLString }, + intVar: { type: GraphQLInt }, + }), +}); + +const InputVars = new GraphQLInputObjectType({ + name: 'VarsInput', + fields: () => ({ + stringVar: { type: GraphQLString }, + intVar: { type: GraphQLInt }, + }), +}); export const schema = new GraphQLSchema({ query: new GraphQLObjectType({ @@ -28,6 +44,20 @@ export const schema = new GraphQLSchema({ }), resolve: () => 3, }, + echoNum: { + type: GraphQLInt, + args: { + 'intVar': { type: GraphQLInt }, + }, + resolve: () => 777, + }, + echoVars: { + type: TypeVars, + args: { + 'vars': { type: InputVars }, + }, + resolve: vars => vars, + }, }, }), }); diff --git a/packages/insomnia-smoke-test/tests/smoke/graphql.test.ts b/packages/insomnia-smoke-test/tests/smoke/graphql.test.ts index 9e427aaba0..2a4bc1e73b 100644 --- a/packages/insomnia-smoke-test/tests/smoke/graphql.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/graphql.test.ts @@ -18,6 +18,7 @@ test('can render schema and send GraphQL requests', async ({ app, page }) => { await page.getByRole('button', { name: 'Scan' }).click(); await page.getByRole('dialog').getByRole('button', { name: 'Import' }).click(); await page.getByText('CollectionSmoke GraphQLjust now').click(); + // Open the graphql request await page.getByLabel('Request Collection').getByTestId('GraphQL request').press('Enter'); // Assert the schema is fetched after switching to GraphQL request @@ -43,6 +44,88 @@ test('can render schema and send GraphQL requests', async ({ app, page }) => { await expect(responseBody).toContainText('"bearer": "Gandalf"'); }); +test('can render schema and send GraphQL requests with object variables', async ({ app, page }) => { + test.slow(process.platform === 'darwin' || process.platform === 'win32', 'Slow app start on these platforms'); + + await page.getByRole('button', { name: 'Create in project' }).click(); + + // Copy the collection with the graphql query to clipboard + const text = await loadFixture('graphql.yaml'); + await app.evaluate(async ({ clipboard }, text) => clipboard.writeText(text), text); + + // Import from clipboard + await page.getByRole('menuitemradio', { name: 'Import' }).click(); + await page.locator('[data-test-id="import-from-clipboard"]').click(); + await page.getByRole('button', { name: 'Scan' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Import' }).click(); + await page.getByText('CollectionSmoke GraphQLjust now').click(); + + // Open the graphql request + await page.getByLabel('Request Collection').getByTestId('GraphQL request with variables').press('Enter'); + // Assert the schema is fetched after switching to GraphQL request + await expect(page.locator('.graphql-editor__meta')).toContainText('schema fetched just now'); + + // Assert schema documentation stuff + await page.getByRole('button', { name: 'schema' }).click(); + await page.getByRole('menuitem', { name: 'Show Documentation' }).click(); + await page.click('a:has-text("Query")'); + await page.locator('a:has-text("RingBearer")').click(); + const graphqlExplorer2 = page.locator('.graphql-explorer'); + await expect(graphqlExplorer2).toContainText('Characters who at any time bore a Ring of Power.'); + await page.click('text=QueryRingBearer >> button'); + + // Send and assert GraphQL request + await page.click('[data-testid="request-pane"] >> text=Send'); + const statusTag2 = page.locator('[data-testid="response-status-tag"]:visible'); + await expect(statusTag2).toContainText('200 OK'); + + const responseBody2 = page.locator('[data-testid="response-pane"] >> [data-testid="CodeEditor"]:visible', { + has: page.locator('.CodeMirror-activeline'), + }); + await expect(responseBody2).toContainText('"echoVars": null'); +}); + +test('can render numeric environment', async ({ app, page }) => { + test.slow(process.platform === 'darwin' || process.platform === 'win32', 'Slow app start on these platforms'); + + await page.getByRole('button', { name: 'Create in project' }).click(); + + // Copy the collection with the graphql query to clipboard + const text = await loadFixture('graphql.yaml'); + await app.evaluate(async ({ clipboard }, text) => clipboard.writeText(text), text); + + // Import from clipboard + await page.getByRole('menuitemradio', { name: 'Import' }).click(); + await page.locator('[data-test-id="import-from-clipboard"]').click(); + await page.getByRole('button', { name: 'Scan' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Import' }).click(); + await page.getByText('CollectionSmoke GraphQLjust now').click(); + + // Open the graphql request + await page.getByLabel('Request Collection').getByTestId('GraphQL request with number').press('Enter'); + // Assert the schema is fetched after switching to GraphQL request + await expect(page.locator('.graphql-editor__meta')).toContainText('schema fetched just now'); + + // Assert schema documentation stuff + await page.getByRole('button', { name: 'schema' }).click(); + await page.getByRole('menuitem', { name: 'Show Documentation' }).click(); + await page.click('a:has-text("Query")'); + await page.locator('a:has-text("RingBearer")').click(); + const graphqlExplorer2 = page.locator('.graphql-explorer'); + await expect(graphqlExplorer2).toContainText('Characters who at any time bore a Ring of Power.'); + await page.click('text=QueryRingBearer >> button'); + + // Send and assert GraphQL request + await page.click('[data-testid="request-pane"] >> text=Send'); + const statusTag2 = page.locator('[data-testid="response-status-tag"]:visible'); + await expect(statusTag2).toContainText('200 OK'); + + const responseBody2 = page.locator('[data-testid="response-pane"] >> [data-testid="CodeEditor"]:visible', { + has: page.locator('.CodeMirror-activeline'), + }); + await expect(responseBody2).toContainText('"echoNum": 777'); +}); + test('can send GraphQL requests after editing and prettifying query', async ({ app, page }) => { test.slow(process.platform === 'darwin' || process.platform === 'win32', 'Slow app start on these platforms'); diff --git a/packages/insomnia/src/network/unit-test-feature.ts b/packages/insomnia/src/network/unit-test-feature.ts index bf01f0349f..48d223d48a 100644 --- a/packages/insomnia/src/network/unit-test-feature.ts +++ b/packages/insomnia/src/network/unit-test-feature.ts @@ -16,6 +16,20 @@ export function getSendRequestCallback() { const renderResult = await tryToInterpolateRequest(request, environment._id, RENDER_PURPOSE_SEND); const renderedRequest = await tryToTransformRequestWithPlugins(renderResult); + + // TODO: remove this temporary hack to support GraphQL variables in the request body properly + if (renderedRequest && renderedRequest.body?.text && renderedRequest.body?.mimeType === 'application/graphql') { + try { + const parsedBody = JSON.parse(renderedRequest.body.text); + if (typeof parsedBody.variables === 'string') { + parsedBody.variables = JSON.parse(parsedBody.variables); + renderedRequest.body.text = JSON.stringify(parsedBody, null, 2); + } + } catch (e) { + console.error('Failed to parse GraphQL variables', e); + } + } + const response = await sendCurlAndWriteTimeline( renderedRequest, clientCertificates, diff --git a/packages/insomnia/src/ui/routes/request.tsx b/packages/insomnia/src/ui/routes/request.tsx index 3e8469734b..f96fb65b73 100644 --- a/packages/insomnia/src/ui/routes/request.tsx +++ b/packages/insomnia/src/ui/routes/request.tsx @@ -340,6 +340,19 @@ export const sendAction: ActionFunction = async ({ request, params }) => { const renderedResult = await tryToInterpolateRequest(req, environment._id, RENDER_PURPOSE_SEND); const renderedRequest = await tryToTransformRequestWithPlugins(renderedResult); + // TODO: remove this temporary hack to support GraphQL variables in the request body properly + if (renderedRequest && renderedRequest.body?.text && renderedRequest.body?.mimeType === 'application/graphql') { + try { + const parsedBody = JSON.parse(renderedRequest.body.text); + if (typeof parsedBody.variables === 'string') { + parsedBody.variables = JSON.parse(parsedBody.variables); + renderedRequest.body.text = JSON.stringify(parsedBody, null, 2); + } + } catch (e) { + console.error('Failed to parse GraphQL variables', e); + } + } + const response = await sendCurlAndWriteTimeline( renderedRequest, clientCertificates, diff --git a/packages/insomnia/src/utils/prettify/json.ts b/packages/insomnia/src/utils/prettify/json.ts index d788fe46f1..ef271e4397 100644 --- a/packages/insomnia/src/utils/prettify/json.ts +++ b/packages/insomnia/src/utils/prettify/json.ts @@ -1,3 +1,5 @@ +import { SentryError } from '@sentry/utils'; + const STATE_IN_NUN_VAR = 'nunvar'; const STATE_IN_NUN_TAG = 'nuntag'; const STATE_IN_NUN_COM = 'nuncom'; @@ -26,25 +28,41 @@ const NUNJUCKS_CLOSE_STATES: { '#}': STATE_IN_NUN_COM, }; +function ensureStringify(val?: string | Object): string { + let defaultVal = ''; + if (!val) { + return defaultVal; + } + + if (typeof val === 'object') { + try { + defaultVal = JSON.stringify(val); + } catch (error) { + SentryError.captureStackTrace(error); + } + + return defaultVal; + } + + return val; +} + /** * Format a JSON string without parsing it as JavaScript. * * Code taken from jsonlint (http://zaa.ch/jsonlint/) */ -export const jsonPrettify = (json?: string, indentChars = '\t', replaceUnicode = true) => { - if (!json) { - return ''; - } - - if (!json.includes('{') && !json.includes('[') && !json.includes('"')) { - return json; +export const jsonPrettify = (json?: string | Object, indentChars = '\t', replaceUnicode = true) => { + let prePrettify = ensureStringify(json); + if (!prePrettify.includes('{') && !prePrettify.includes('[') && !prePrettify.includes('"')) { + return prePrettify; } // Convert the unicode. To correctly mimic JSON.stringify(JSON.parse(json), null, indentChars) // we need to convert all escaped unicode characters to proper unicode characters. if (replaceUnicode) { try { - json = convertUnicode(json); + prePrettify = convertUnicode(prePrettify); } catch (err) { // Just in case (should never happen) console.warn('Prettify failed to handle unicode', err); @@ -52,7 +70,7 @@ export const jsonPrettify = (json?: string, indentChars = '\t', replaceUnicode = } let i = 0; - const il = json.length; + const il = prePrettify.length; const tab = indentChars; let newJson = ''; let indentLevel = 0; @@ -62,8 +80,8 @@ export const jsonPrettify = (json?: string, indentChars = '\t', replaceUnicode = let state = STATE_NONE; for (; i < il; i += 1) { - currentChar = json.charAt(i); - nextChar = json.charAt(i + 1) || ''; + currentChar = prePrettify.charAt(i); + nextChar = prePrettify.charAt(i + 1) || ''; nextTwo = currentChar + nextChar; if (state === STATE_IN_STRING) {