Skip to content

Commit 8587afc

Browse files
feat: Detect GraphQL operation names and types in AJAX calls (#764)
1 parent 454b43d commit 8587afc

File tree

17 files changed

+521
-16
lines changed

17 files changed

+521
-16
lines changed

src/cdn/polyfills.js

+3
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,11 @@ import 'core-js/stable/array/some'
1313
import 'core-js/stable/object/assign'
1414
import 'core-js/stable/object/entries'
1515
import 'core-js/stable/object/values'
16+
import 'core-js/stable/object/from-entries'
1617
import 'core-js/stable/map'
1718
import 'core-js/stable/reflect'
1819
import 'core-js/stable/set'
1920
import 'core-js/stable/weak-set'
2021
import 'core-js/stable/object/get-own-property-descriptors'
22+
import 'core-js/stable/url'
23+
import 'core-js/stable/url-search-params'

src/common/url/parse-url.js

+9-7
Original file line numberDiff line numberDiff line change
@@ -23,20 +23,22 @@ export function parseUrl (url) {
2323
var location = globalScope?.location
2424
var ret = {}
2525

26-
if (isBrowserScope) {
26+
try {
27+
urlEl = new URL(url, location.href)
28+
} catch (err) {
29+
if (isBrowserScope) {
2730
// Use an anchor dom element to resolve the url natively.
28-
urlEl = document.createElement('a')
29-
urlEl.href = url
30-
} else {
31-
try {
32-
urlEl = new URL(url, location.href)
33-
} catch (err) {
31+
urlEl = document.createElement('a')
32+
urlEl.href = url
33+
} else {
3434
return ret
3535
}
3636
}
3737

3838
ret.port = urlEl.port
3939

40+
ret.search = urlEl.search
41+
4042
var firstSplit = urlEl.href.split('://')
4143

4244
if (!ret.port && firstSplit[1]) {

src/common/url/parse-url.test.js

+40-6
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ const urlTests = [
1111
pathname: '/path/name',
1212
protocol: 'http',
1313
port: '80',
14-
sameOrigin: false
14+
sameOrigin: false,
15+
search: '?qs=5&a=b'
1516
}
1617
},
1718
{
@@ -21,7 +22,8 @@ const urlTests = [
2122
pathname: '/path/@name',
2223
protocol: 'http',
2324
port: '8080',
24-
sameOrigin: false
25+
sameOrigin: false,
26+
search: '?qs=5&a=b'
2527
}
2628
},
2729
{
@@ -31,7 +33,8 @@ const urlTests = [
3133
pathname: '/path/name',
3234
protocol: 'https',
3335
port: '443',
34-
sameOrigin: false
36+
sameOrigin: false,
37+
search: '?qs=5&a=b'
3538
}
3639
},
3740
{
@@ -41,7 +44,8 @@ const urlTests = [
4144
pathname: '/path/name',
4245
protocol: location.protocol.split(':')[0],
4346
port: '80',
44-
sameOrigin: true
47+
sameOrigin: true,
48+
search: '?qs=5&a=b'
4549
}
4650
},
4751
{
@@ -51,7 +55,8 @@ const urlTests = [
5155
pathname: '/path/name',
5256
protocol: location.protocol.split(':')[0],
5357
port: '80',
54-
sameOrigin: true
58+
sameOrigin: true,
59+
search: '?qs=5&a=b'
5560
}
5661
},
5762
{
@@ -97,7 +102,36 @@ test('should cache parsed urls', async () => {
97102
pathname: '/',
98103
protocol: 'http',
99104
port: '80',
100-
sameOrigin: false
105+
sameOrigin: false,
106+
search: ''
107+
}
108+
109+
jest.spyOn(document, 'createElement')
110+
111+
const { parseUrl } = await import('./parse-url')
112+
parseUrl(input)
113+
114+
expect(parseUrl(input)).toEqual(expected)
115+
expect(document.createElement).toHaveBeenCalledTimes(0)
116+
})
117+
118+
test('should use createElement as fallback', async () => {
119+
jest.doMock('../constants/runtime', () => ({
120+
__esModule: true,
121+
isBrowserScope: true,
122+
globalScope: global
123+
}))
124+
125+
global.URL = jest.fn(() => { throw new Error('test') })
126+
127+
const input = 'http://example.com/'
128+
const expected = {
129+
hostname: 'example.com',
130+
pathname: '/',
131+
protocol: 'http',
132+
port: '80',
133+
sameOrigin: false,
134+
search: ''
101135
}
102136

103137
jest.spyOn(document, 'createElement')

src/common/util/type-check.js

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/**
2+
* Tests a passed object to see if it is a pure object or not. All non-primatives in JS
3+
* are technically objects and would pass a `typeof` check.
4+
* @param {*} obj Input object to be tested
5+
**/
6+
export function isPureObject (obj) {
7+
return obj?.constructor === ({}).constructor
8+
}

src/common/util/type-check.test.js

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { isPureObject } from './type-check'
2+
3+
test('isPureObject', () => {
4+
expect(isPureObject([])).toEqual(false)
5+
expect(isPureObject(1)).toEqual(false)
6+
expect(isPureObject('string')).toEqual(false)
7+
expect(isPureObject(new Blob([]))).toEqual(false)
8+
expect(isPureObject(new Date())).toEqual(false)
9+
expect(isPureObject(null)).toEqual(false)
10+
expect(isPureObject(undefined)).toEqual(false)
11+
expect(isPureObject(function () {})).toEqual(false)
12+
expect(isPureObject(/./)).toEqual(false)
13+
expect(isPureObject(window.location)).toEqual(false)
14+
expect(isPureObject(Object.create(null))).toEqual(false)
15+
16+
expect(isPureObject({ test: 1 })).toEqual(true)
17+
expect(isPureObject({})).toEqual(true)
18+
expect(isPureObject(new Object())).toEqual(true) // eslint-disable-line
19+
expect(isPureObject(JSON.parse('{"test": 1}'))).toEqual(true)
20+
})

src/features/ajax/aggregate/gql.js

+95
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { isPureObject } from '../../../common/util/type-check'
2+
3+
/**
4+
* @typedef {object} GQLMetadata
5+
* @property {string} operationName Name of the operation
6+
* @property {string} operationType Type of the operation
7+
* @property {string} operationFramework Framework responsible for the operation
8+
*/
9+
10+
/**
11+
* Parses and returns the graphql metadata from a network request. If the network
12+
* request is not a graphql call, undefined will be returned.
13+
* @param {object|string} body Ajax request body
14+
* @param {string} query Ajax request query param string
15+
* @returns {GQLMetadata | undefined}
16+
*/
17+
export function parseGQL ({ body, query } = {}) {
18+
if (!body && !query) return
19+
try {
20+
const gqlBody = parseBatchGQL(parseGQLContents(body))
21+
if (gqlBody) return gqlBody
22+
const gqlQuery = parseSingleGQL(parseGQLQueryString(query))
23+
if (gqlQuery) return gqlQuery
24+
} catch (err) {
25+
// parsing failed, return undefined
26+
}
27+
}
28+
29+
/**
30+
* @param {string|Object} gql The GraphQL object body sent to a GQL server
31+
* @returns {GQLMetadata}
32+
*/
33+
function parseSingleGQL (contents) {
34+
if (typeof contents !== 'object' || !contents.query || typeof contents.query !== 'string') return
35+
36+
/** parses gql query string and returns [fullmatch, type match, name match] */
37+
const matches = contents.query.trim().match(/^(query|mutation|subscription)\s?(\w*)/)
38+
const operationType = matches?.[1]
39+
if (!operationType) return
40+
const operationName = contents.operationName || matches?.[2] || 'Anonymous'
41+
return {
42+
operationName, // the operation name of the indiv query
43+
operationType, // query, mutation, or subscription,
44+
operationFramework: 'GraphQL'
45+
}
46+
}
47+
48+
function parseBatchGQL (contents) {
49+
if (!contents) return
50+
if (!Array.isArray(contents)) contents = [contents]
51+
52+
const opNames = []
53+
const opTypes = []
54+
for (let content of contents) {
55+
const operation = parseSingleGQL(content)
56+
if (!operation) continue
57+
58+
opNames.push(operation.operationName)
59+
opTypes.push(operation.operationType)
60+
}
61+
62+
if (!opTypes.length) return
63+
return {
64+
operationName: opNames.join(','), // the operation name of the indiv query -- joined by ',' for batched results
65+
operationType: opTypes.join(','), // query, mutation, or subscription -- joined by ',' for batched results
66+
operationFramework: 'GraphQL'
67+
}
68+
}
69+
70+
function parseGQLContents (gqlContents) {
71+
let contents
72+
73+
if (!gqlContents || (typeof gqlContents !== 'string' && typeof gqlContents !== 'object')) return
74+
else if (typeof gqlContents === 'string') contents = JSON.parse(gqlContents)
75+
else contents = gqlContents
76+
77+
if (!isPureObject(contents) && !Array.isArray(contents)) return
78+
79+
let isValid = false
80+
if (Array.isArray(contents)) isValid = contents.some(x => validateGQLObject(x))
81+
else isValid = validateGQLObject(contents)
82+
83+
if (!isValid) return
84+
return contents
85+
}
86+
87+
function parseGQLQueryString (gqlQueryString) {
88+
if (!gqlQueryString || typeof gqlQueryString !== 'string') return
89+
const params = new URLSearchParams(gqlQueryString)
90+
return parseGQLContents(Object.fromEntries(params))
91+
}
92+
93+
function validateGQLObject (obj) {
94+
return !(typeof obj !== 'object' || !obj.query || typeof obj.query !== 'string')
95+
}

0 commit comments

Comments
 (0)