Skip to content

Commit

Permalink
feat: use cross-fetch as ponyfill
Browse files Browse the repository at this point in the history
This means that users no longer need to think about polyfilling fetch in
their environment. This is especially helpful for node users.

This could be considered a breaking change because by taking a ponyfill
approach we're no longer relying on the host platform fetch
implementation, and that might lead to some unexpected behaviours. In
practice it should be fine given the battle tested libs under use here
and the simple use-case graphql-request has for them.
  • Loading branch information
jasonkuhrt committed May 28, 2020
1 parent 483119e commit 1610d1e
Show file tree
Hide file tree
Showing 6 changed files with 573 additions and 141 deletions.
10 changes: 8 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,21 +42,27 @@
"test:ci": "yarn test && bundlesize",
"semantic-release": "semantic-release"
},
"dependencies": {},
"dependencies": {
"cross-fetch": "^3.0.4"
},
"devDependencies": {
"@prisma-labs/prettier-config": "^0.1.0",
"@types/body-parser": "^1.19.0",
"@types/express": "^4.17.6",
"@types/fetch-mock": "5.12.2",
"@types/jest": "^25.2.3",
"@types/node": "10.17.24",
"body-parser": "^1.19.0",
"bundlesize": "^0.18.0",
"express": "^4.17.1",
"fetch-cookie": "0.7.2",
"fetch-mock": "5.13.1",
"jest": "^26.0.1",
"prettier": "^2.0.5",
"semantic-release": "17.0.8",
"ts-jest": "^26.0.0",
"tslint": "5.11.0",
"tslint-config-standard": "8.0.1",
"type-fest": "^0.15.0",
"typescript": "^3.9.3"
},
"prettier": "@prisma-labs/prettier-config",
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/// <reference lib="dom" />

import fetch from 'cross-fetch'
import { ClientError, GraphQLError, Variables } from './types'

export { ClientError } from './types'
Expand Down
56 changes: 56 additions & 0 deletions tests/__helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import body from 'body-parser'
import express, { Application, Request } from 'express'
import { createServer, Server } from 'http'
import { JsonObject } from 'type-fest'

type CapturedRequest = Pick<Request, 'headers' | 'method' | 'body'>

type Context = {
server: Application
nodeServer: Server
url: string
mock: <D extends JsonObject>(data: D) => D & { requests: CapturedRequest[] }
}

export function setupTestServer() {
const ctx = {} as Context
beforeAll((done) => {
ctx.server = express()
ctx.server.use(body.json())
ctx.nodeServer = createServer()
ctx.nodeServer.listen({ port: 3210 })
ctx.url = 'http://localhost:3210'
ctx.nodeServer.on('request', ctx.server)
ctx.nodeServer.once('listening', done)
ctx.mock = (spec) => {
const requests: CapturedRequest[] = []
ctx.server.use('*', function mock(req, res) {
requests.push({
method: req.method,
headers: req.headers,
body: req.body,
})
if (spec.headers) {
Object.entries(spec.headers).forEach(([name, value]) => {
res.setHeader(name, value)
})
}
res.send(spec.body ?? {})
})
return { ...spec, requests }
}
})

afterEach(() => {
// https://stackoverflow.com/questions/10378690/remove-route-mappings-in-nodejs-express/28369539#28369539
ctx.server._router.stack.forEach((item, i) => {
if (item.name === 'mock') ctx.server._router.stack.splice(i, 1)
})
})

afterAll((done) => {
ctx.nodeServer.close(done)
})

return ctx
}
206 changes: 110 additions & 96 deletions tests/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,139 +1,153 @@
import * as fetchMock from 'fetch-mock'
import { GraphQLClient, rawRequest, request } from '../src'
import { setupTestServer } from './__helpers'

const ctx = setupTestServer()

test('minimal query', async () => {
const data = {
viewer: {
id: 'some-id',
const data = ctx.mock({
body: {
data: {
viewer: {
id: 'some-id',
},
},
},
}
}).body.data

mock({ body: { data } })
expect(await request('https://mock-api.com/graphql', `{ viewer { id } }`)).toEqual(data)
expect(await request(ctx.url, `{ viewer { id } }`)).toEqual(data)
})

test('minimal raw query', async () => {
const data = {
viewer: {
id: 'some-id',
const { extensions, data } = ctx.mock({
body: {
data: {
viewer: {
id: 'some-id',
},
},
extensions: {
version: '1',
},
},
}

const extensions = {
version: '1',
}

mock({ body: { data, extensions } })
const { headers, ...result } = await rawRequest('https://mock-api.com/graphql', `{ viewer { id } }`)
}).body
const { headers, ...result } = await rawRequest(ctx.url, `{ viewer { id } }`)
expect(result).toEqual({ data, extensions, status: 200 })
})

test('minimal raw query with response headers', async () => {
const data = {
viewer: {
id: 'some-id',
const {
headers: reqHeaders,
body: { data, extensions },
} = ctx.mock({
headers: {
'Content-Type': 'application/json',
'X-Custom-Header': 'test-custom-header',
},
}

const extensions = {
version: '1',
}

const reqHeaders = {
'Content-Type': 'application/json',
'X-Custom-Header': 'test-custom-header',
}

mock({ headers: reqHeaders, body: { data, extensions } })
const { headers, ...result } = await rawRequest('https://mock-api.com/graphql', `{ viewer { id } }`)
body: {
data: {
viewer: {
id: 'some-id',
},
},
extensions: {
version: '1',
},
},
})
const { headers, ...result } = await rawRequest(ctx.url, `{ viewer { id } }`)

expect(result).toEqual({ data, extensions, status: 200 })
expect(headers.get('X-Custom-Header')).toEqual(reqHeaders['X-Custom-Header'])
})

test('basic error', async () => {
const errors = {
message: 'Syntax Error GraphQL request (1:1) Unexpected Name "x"\n\n1: x\n ^\n',
locations: [
{
line: 1,
column: 1,
test('content-type with charset', async () => {
const { data } = ctx.mock({
// headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: {
data: {
viewer: {
id: 'some-id',
},
},
],
}
},
}).body

mock({ body: { errors } })
expect(() => request('https://mock-api.com/graphql', `x`)).rejects.toThrowErrorMatchingInlineSnapshot(
`"GraphQL Error (Code: 200): {\\"response\\":{\\"errors\\":{\\"message\\":\\"Syntax Error GraphQL request (1:1) Unexpected Name \\\\\\"x\\\\\\"\\\\n\\\\n1: x\\\\n ^\\\\n\\",\\"locations\\":[{\\"line\\":1,\\"column\\":1}]},\\"status\\":200},\\"request\\":{\\"query\\":\\"x\\"}}"`
)
expect(await request(ctx.url, `{ viewer { id } }`)).toEqual(data)
})

test('raw request error', async () => {
const errors = {
message: 'Syntax Error GraphQL request (1:1) Unexpected Name "x"\n\n1: x\n ^\n',
locations: [
{
line: 1,
column: 1,
test('basic error', async () => {
ctx.mock({
body: {
errors: {
message: 'Syntax Error GraphQL request (1:1) Unexpected Name "x"\n\n1: x\n ^\n',
locations: [
{
line: 1,
column: 1,
},
],
},
],
}
},
}).body

const res = await request(ctx.url, `x`).catch((x) => x)

mock({ body: { errors } })
expect(rawRequest('https://mock-api.com/graphql', `x`)).rejects.toThrowErrorMatchingInlineSnapshot(
`"GraphQL Error (Code: 200): {\\"response\\":{\\"errors\\":{\\"message\\":\\"Syntax Error GraphQL request (1:1) Unexpected Name \\\\\\"x\\\\\\"\\\\n\\\\n1: x\\\\n ^\\\\n\\",\\"locations\\":[{\\"line\\":1,\\"column\\":1}]},\\"status\\":200,\\"headers\\":{\\"_headers\\":{\\"content-type\\":[\\"application/json\\"]}}},\\"request\\":{\\"query\\":\\"x\\"}}"`
expect(res).toMatchInlineSnapshot(
`[Error: GraphQL Error (Code: 200): {"response":{"errors":{"message":"Syntax Error GraphQL request (1:1) Unexpected Name \\"x\\"\\n\\n1: x\\n ^\\n","locations":[{"line":1,"column":1}]},"status":200},"request":{"query":"x"}}]`
)
})

test('content-type with charset', async () => {
const data = {
viewer: {
id: 'some-id',
test('basic error with raw request', async () => {
ctx.mock({
body: {
errors: {
message: 'Syntax Error GraphQL request (1:1) Unexpected Name "x"\n\n1: x\n ^\n',
locations: [
{
line: 1,
column: 1,
},
],
},
},
}

mock({
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: { data },
})
expect(await request('https://mock-api.com/graphql', `{ viewer { id } }`)).toEqual(data)
const res = await rawRequest(ctx.url, `x`).catch((x) => x)
expect(res).toMatchInlineSnapshot(
`[Error: GraphQL Error (Code: 200): {"response":{"errors":{"message":"Syntax Error GraphQL request (1:1) Unexpected Name \\"x\\"\\n\\n1: x\\n ^\\n","locations":[{"line":1,"column":1}]},"status":200,"headers":{}},"request":{"query":"x"}}]`
)
})

test('extra fetch options', async () => {
// todo needs to be tested in browser environment
// the options under test here aren't used by node-fetch
test.skip('extra fetch options', async () => {
const options: RequestInit = {
credentials: 'include',
mode: 'cors',
cache: 'reload',
}

const client = new GraphQLClient('https://mock-api.com/graphql', options)
mock({
const client = new GraphQLClient(ctx.url, options)
const { requests } = ctx.mock({
body: { data: { test: 'test' } },
})
await client.request('{ test }')
const actualOptions = fetchMock.lastCall()[1]
for (let name in options) {
expect(actualOptions[name]).toEqual(options[name])
}
})

/**
* Helpers
*/

async function mock(response: any) {
fetchMock.mock({
matcher: '*',
response: {
headers: {
'Content-Type': 'application/json',
...response.headers,
expect(requests).toMatchInlineSnapshot(`
Array [
Object {
"body": Object {
"query": "{ test }",
},
"headers": Object {
"accept": "*/*",
"accept-encoding": "gzip,deflate",
"connection": "close",
"content-length": "20",
"content-type": "application/json",
"host": "localhost:3210",
"user-agent": "node-fetch/1.0 (+https://github.com/bitinn/node-fetch)",
},
"method": "POST",
},
body: JSON.stringify(response.body),
},
})
}

afterEach(() => {
fetchMock.restore()
]
`)
})
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"lib": ["es2015"],
"module": "commonjs",
"moduleResolution": "node",
"esModuleInterop": true,
"noUnusedLocals": true,
"outDir": "dist",
"rootDir": "src",
Expand Down
Loading

2 comments on commit 1610d1e

@cpmech
Copy link

@cpmech cpmech commented on 1610d1e Feb 6, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it's a breaking change! at least for tests. Any suggestions on how we can still keep using fetch-mock?

@cpmech
Copy link

@cpmech cpmech commented on 1610d1e Feb 7, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

had to remove fetch-mock... a bit of a headache but am using your __helpers.ts file now...

my changes: cpmech/simple-state@25ca86e

Please sign in to comment.