Skip to content

Commit f5a6476

Browse files
authored
fix: spec compliant accept type (#1064)
1 parent bb48aee commit f5a6476

File tree

15 files changed

+731
-877
lines changed

15 files changed

+731
-877
lines changed

eslint.config.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@ import tsEslint from 'typescript-eslint'
33

44
export default tsEslint.config({
55
ignores: [
6-
'**/build/**/*',
76
'eslint.config.js',
87
'vite.config.ts',
98
'**/generated/**/*',
109
'**/$generated-clients/**/*',
11-
'**/website/**/*',
12-
'**/website/.vitepress/**/*',
10+
'legacy/**/*',
11+
'build/**/*',
12+
'website/**/*',
1313
],
1414
extends: configPrisma,
1515
languageOptions: {

examples/transport-http_RequestInput.output.txt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
body: '{"query":"{ languages { code } }"}',
44
method: 'POST',
55
headers: Headers {
6-
authorization: 'Bearer MY_TOKEN',
7-
accept: 'application/graphql-response+json',
8-
'content-type': 'application/json'
6+
accept: 'application/graphql-response+json; charset=utf-8, application/json; charset=utf-8',
7+
'content-type': 'application/json',
8+
authorization: 'Bearer MY_TOKEN'
99
},
1010
mode: 'cors'
1111
}

package.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -114,12 +114,12 @@
114114
"@types/body-parser": "^1.19.5",
115115
"@types/express": "^4.17.21",
116116
"@types/json-bigint": "^1.0.4",
117-
"@types/node": "^22.5.1",
118-
"@typescript-eslint/eslint-plugin": "^8.3.0",
119-
"@typescript-eslint/parser": "^8.3.0",
117+
"@types/node": "^22.5.4",
118+
"@typescript-eslint/eslint-plugin": "^8.4.0",
119+
"@typescript-eslint/parser": "^8.4.0",
120120
"doctoc": "^2.2.1",
121121
"dripip": "^0.10.0",
122-
"es-toolkit": "^1.16.0",
122+
"es-toolkit": "^1.17.0",
123123
"eslint": "^9.9.1",
124124
"eslint-config-prisma": "^0.6.0",
125125
"eslint-plugin-deprecation": "^3.0.0",
@@ -143,7 +143,7 @@
143143
"tsx": "^4.19.0",
144144
"type-fest": "^4.26.0",
145145
"typescript": "^5.5.4",
146-
"typescript-eslint": "^8.3.0",
146+
"typescript-eslint": "^8.4.0",
147147
"vitepress": "^1.3.4",
148148
"vitest": "^2.0.5"
149149
}

pnpm-lock.yaml

Lines changed: 246 additions & 291 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/layers/5_core/core.ts

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ import type { DocumentNode, ExecutionResult, GraphQLSchema } from 'graphql'
22
import { print } from 'graphql'
33
import { Anyware } from '../../lib/anyware/__.js'
44
import { type StandardScalarVariables } from '../../lib/graphql.js'
5-
import { CONTENT_TYPE_GQL_OVER_HTTP_REC, parseExecutionResult } from '../../lib/graphqlHTTP.js'
6-
import { CONTENT_TYPE_JSON, mergeHeadersInit } from '../../lib/http.js'
5+
import { ACCEPT_REC, CONTENT_TYPE_REC, parseExecutionResult } from '../../lib/graphqlHTTP.js'
76
import { casesExhausted } from '../../lib/prelude.js'
87
import { execute } from '../0_functions/execute.js'
98
import type { Schema } from '../1_Schema/__.js'
@@ -213,16 +212,20 @@ export const anyware = Anyware.create<HookSequence, HookMap, ExecutionResult>({
213212
body: input.body,
214213
// @see https://graphql.github.io/graphql-over-http/draft/#sec-POST
215214
method: `POST`,
216-
...mergeRequestInputOptions(input.context.config.requestInputOptions, {
217-
headers: mergeHeadersInit(input.headers, {
218-
accept: CONTENT_TYPE_GQL_OVER_HTTP_REC,
219-
// todo if body is something else, say upload extension turns it into a FormData, then fetch will automatically set the content-type header.
220-
// ... however we should not rely on that behavior, and instead error here if there is no content type header and we cannot infer it here?
221-
...(typeof input.body === `string`
222-
? { 'content-type': CONTENT_TYPE_JSON }
223-
: {}),
224-
}),
225-
}),
215+
...mergeRequestInputOptions(
216+
mergeRequestInputOptions(
217+
{
218+
headers: {
219+
accept: ACCEPT_REC,
220+
'content-type': CONTENT_TYPE_REC,
221+
},
222+
},
223+
input.context.config.requestInputOptions,
224+
),
225+
{
226+
headers: input.headers,
227+
},
228+
),
226229
}
227230
return {
228231
...input,
@@ -243,7 +246,9 @@ export const anyware = Anyware.create<HookSequence, HookMap, ExecutionResult>({
243246
switch (input.transport) {
244247
case `http`: {
245248
const request = new Request(input.request.url, input.request)
249+
// console.log(request)
246250
const response = await slots.fetch(request)
251+
// console.log(response)
247252
return {
248253
...input,
249254
response,
@@ -270,7 +275,7 @@ export const anyware = Anyware.create<HookSequence, HookMap, ExecutionResult>({
270275
switch (input.transport) {
271276
case `http`: {
272277
// todo 1 if response is missing header of content length then .json() hangs forever.
273-
// todo 1 firstly consider a timeout, secondly, if response is malformed, then don't even run .json()
278+
// firstly consider a timeout, secondly, if response is malformed, then don't even run .json()
274279
// todo 2 if response is e.g. 404 with no json body, then an error is thrown because json parse cannot work, not gracefully handled here
275280
const json = await input.response.json() as object
276281
const result = parseExecutionResult(json)

src/layers/6_client/client.test.ts

Lines changed: 0 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,6 @@ import { createResponse, test } from '../../../tests/_/helpers.js'
33
import { Graffle as Graffle2 } from '../../../tests/_/schema/generated/__.js'
44
import { schema } from '../../../tests/_/schema/schema.js'
55
import { Graffle } from '../../entrypoints/main.js'
6-
import { CONTENT_TYPE_GQL, CONTENT_TYPE_JSON } from '../../lib/http.js'
7-
import { Transport } from '../5_core/types.js'
8-
import type { RequestInput } from './Settings/inputIncrementable/request.js'
96

107
const endpoint = new URL(`https://foo.io/api/graphql`)
118

@@ -29,107 +26,6 @@ describe(`without schemaIndex only raw is available`, () => {
2926
})
3027
})
3128

32-
describe(`transport`, () => {
33-
describe(`http`, () => {
34-
test(`anyware hooks are typed to http transport`, () => {
35-
Graffle.create({ schema: endpoint }).use(async ({ encode }) => {
36-
expectTypeOf(encode.input.transport).toEqualTypeOf(Transport.http)
37-
const { pack } = await encode()
38-
expectTypeOf(pack.input.transport).toEqualTypeOf(Transport.http)
39-
const { exchange } = await pack()
40-
expectTypeOf(exchange.input.transport).toEqualTypeOf(Transport.http)
41-
expectTypeOf(exchange.input.request).toEqualTypeOf<RequestInput>()
42-
const { unpack } = await exchange()
43-
expectTypeOf(unpack.input.transport).toEqualTypeOf(Transport.http)
44-
expectTypeOf(unpack.input.response).toEqualTypeOf<Response>()
45-
const { decode } = await unpack()
46-
expectTypeOf(decode.input.transport).toEqualTypeOf(Transport.http)
47-
expectTypeOf(decode.input.response).toEqualTypeOf<Response>()
48-
const result = await decode()
49-
if (!(result instanceof Error)) {
50-
expectTypeOf(result.response).toEqualTypeOf<Response>()
51-
}
52-
return result
53-
})
54-
})
55-
test(`can set headers in constructor`, async ({ fetch }) => {
56-
fetch.mockImplementationOnce(() => Promise.resolve(createResponse({ data: { id: `abc` } })))
57-
const graffle = Graffle.create({ schema: endpoint, request: { headers: { 'x-foo': `bar` } } })
58-
await graffle.rawString({ document: `query { id }` })
59-
const request = fetch.mock.calls[0]?.[0]
60-
expect(request?.headers.get(`x-foo`)).toEqual(`bar`)
61-
})
62-
test(`sends spec compliant request`, async ({ fetch, graffle }) => {
63-
fetch.mockImplementationOnce(() => Promise.resolve(createResponse({ data: { greetings: `Hello World` } })))
64-
await graffle.rawString({ document: `query { greetings }` })
65-
const request = fetch.mock.calls[0]?.[0]
66-
expect(request?.headers.get(`content-type`)).toEqual(CONTENT_TYPE_JSON)
67-
expect(request?.headers.get(`accept`)).toEqual(CONTENT_TYPE_GQL)
68-
})
69-
describe(`signal`, () => {
70-
// JSDom and Node result in different errors. JSDom is a plain Error type. Presumably an artifact of JSDom and now in actual browsers.
71-
const abortErrorMessagePattern = /This operation was aborted|AbortError: The operation was aborted/
72-
test(`AbortController at instance level works`, async () => {
73-
const abortController = new AbortController()
74-
const graffle = Graffle.create({
75-
schema: endpoint,
76-
request: { signal: abortController.signal },
77-
})
78-
const resultPromise = graffle.rawString({ document: `query { id }` })
79-
abortController.abort()
80-
const { caughtError } = await resultPromise.catch((caughtError: unknown) => ({ caughtError })) as any as {
81-
caughtError: Error
82-
}
83-
expect(caughtError.message).toMatch(abortErrorMessagePattern)
84-
})
85-
test(`AbortController at method level works`, async () => {
86-
const abortController = new AbortController()
87-
const graffle = Graffle.create({
88-
schema: endpoint,
89-
}).with({ request: { signal: abortController.signal } })
90-
const resultPromise = graffle.rawString({ document: `query { id }` })
91-
abortController.abort()
92-
const { caughtError } = await resultPromise.catch((caughtError: unknown) => ({ caughtError })) as any as {
93-
caughtError: Error
94-
}
95-
expect(caughtError.message).toMatch(abortErrorMessagePattern)
96-
})
97-
})
98-
})
99-
describe(`memory`, () => {
100-
test(`anyware hooks are typed to memory transport`, () => {
101-
Graffle.create({ schema }).use(async ({ encode }) => {
102-
expectTypeOf(encode.input.transport).toEqualTypeOf(Transport.memory)
103-
const { pack } = await encode()
104-
expectTypeOf(pack.input.transport).toEqualTypeOf(Transport.memory)
105-
const { exchange } = await pack()
106-
expectTypeOf(exchange.input.transport).toEqualTypeOf(Transport.memory)
107-
// @ts-expect-error any
108-
exchange.input.request
109-
const { unpack } = await exchange()
110-
expectTypeOf(unpack.input.transport).toEqualTypeOf(Transport.memory)
111-
// @ts-expect-error any
112-
unpack.input.response
113-
const { decode } = await unpack()
114-
expectTypeOf(decode.input.transport).toEqualTypeOf(Transport.memory)
115-
// @ts-expect-error any
116-
decode.input.response
117-
const result = await decode()
118-
if (!(result instanceof Error)) {
119-
// @ts-expect-error any
120-
result.response
121-
}
122-
return result
123-
})
124-
})
125-
test(`cannot set headers in constructor`, () => {
126-
// todo: This error is poor for the user. It refers to schema not being a URL. The better message would be that headers is not allowed with memory transport.
127-
// @ts-expect-error headers not allowed with GraphQL schema
128-
Graffle.create({ schema, request: { headers: { 'x-foo': `bar` } } })
129-
})
130-
})
131-
})
132-
13329
describe(`output`, () => {
13430
test(`when using envelope and transport is http, response property is available`, async ({ fetch }) => {
13531
fetch.mockImplementationOnce(() => Promise.resolve(createResponse({ data: { id: `abc` } })))
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { describe, expect, expectTypeOf } from 'vitest'
2+
import { createResponse, test } from '../../../tests/_/helpers.js'
3+
import { Graffle } from '../../entrypoints/main.js'
4+
import { ACCEPT_REC, CONTENT_TYPE_REC } from '../../lib/graphqlHTTP.js'
5+
import { Transport } from '../5_core/types.js'
6+
import type { RequestInput } from './Settings/inputIncrementable/request.js'
7+
8+
const endpoint = new URL(`https://foo.io/api/graphql`)
9+
10+
test(`anyware hooks are typed to http transport`, () => {
11+
Graffle.create({ schema: endpoint }).use(async ({ encode }) => {
12+
expectTypeOf(encode.input.transport).toEqualTypeOf(Transport.http)
13+
const { pack } = await encode()
14+
expectTypeOf(pack.input.transport).toEqualTypeOf(Transport.http)
15+
const { exchange } = await pack()
16+
expectTypeOf(exchange.input.transport).toEqualTypeOf(Transport.http)
17+
expectTypeOf(exchange.input.request).toEqualTypeOf<RequestInput>()
18+
const { unpack } = await exchange()
19+
expectTypeOf(unpack.input.transport).toEqualTypeOf(Transport.http)
20+
expectTypeOf(unpack.input.response).toEqualTypeOf<Response>()
21+
const { decode } = await unpack()
22+
expectTypeOf(decode.input.transport).toEqualTypeOf(Transport.http)
23+
expectTypeOf(decode.input.response).toEqualTypeOf<Response>()
24+
const result = await decode()
25+
if (!(result instanceof Error)) {
26+
expectTypeOf(result.response).toEqualTypeOf<Response>()
27+
}
28+
return result
29+
})
30+
})
31+
32+
test(`can set headers in constructor`, async ({ fetch }) => {
33+
fetch.mockImplementationOnce(() => Promise.resolve(createResponse({ data: { id: `abc` } })))
34+
const graffle = Graffle.create({ schema: endpoint, request: { headers: { 'x-foo': `bar` } } })
35+
await graffle.rawString({ document: `query { id }` })
36+
const request = fetch.mock.calls[0]?.[0]
37+
expect(request?.headers.get(`x-foo`)).toEqual(`bar`)
38+
})
39+
40+
test(`sends spec compliant request`, async ({ fetch, graffle }) => {
41+
fetch.mockImplementationOnce(() => Promise.resolve(createResponse({ data: { greetings: `Hello World` } })))
42+
await graffle.rawString({ document: `query { greetings }` })
43+
const request = fetch.mock.calls[0]?.[0]
44+
expect(request?.headers.get(`content-type`)).toEqual(CONTENT_TYPE_REC)
45+
expect(request?.headers.get(`accept`)).toEqual(ACCEPT_REC)
46+
})
47+
48+
describe(`signal`, () => {
49+
// JSDom and Node result in different errors. JSDom is a plain Error type. Presumably an artifact of JSDom and now in actual browsers.
50+
const abortErrorMessagePattern = /This operation was aborted|AbortError: The operation was aborted/
51+
test(`AbortController at instance level works`, async () => {
52+
const abortController = new AbortController()
53+
const graffle = Graffle.create({
54+
schema: endpoint,
55+
request: { signal: abortController.signal },
56+
})
57+
const resultPromise = graffle.rawString({ document: `query { id }` })
58+
abortController.abort()
59+
const { caughtError } = await resultPromise.catch((caughtError: unknown) => ({ caughtError })) as any as {
60+
caughtError: Error
61+
}
62+
expect(caughtError.message).toMatch(abortErrorMessagePattern)
63+
})
64+
test(`AbortController at method level works`, async () => {
65+
const abortController = new AbortController()
66+
const graffle = Graffle.create({
67+
schema: endpoint,
68+
}).with({ request: { signal: abortController.signal } })
69+
const resultPromise = graffle.rawString({ document: `query { id }` })
70+
abortController.abort()
71+
const { caughtError } = await resultPromise.catch((caughtError: unknown) => ({ caughtError })) as any as {
72+
caughtError: Error
73+
}
74+
expect(caughtError.message).toMatch(abortErrorMessagePattern)
75+
})
76+
})
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { expectTypeOf } from 'vitest'
2+
import { test } from '../../../tests/_/helpers.js'
3+
import { schema } from '../../../tests/_/schema/schema.js'
4+
import { Graffle } from '../../entrypoints/main.js'
5+
import { Transport } from '../5_core/types.js'
6+
7+
test(`anyware hooks are typed to memory transport`, () => {
8+
Graffle.create({ schema }).use(async ({ encode }) => {
9+
expectTypeOf(encode.input.transport).toEqualTypeOf(Transport.memory)
10+
const { pack } = await encode()
11+
expectTypeOf(pack.input.transport).toEqualTypeOf(Transport.memory)
12+
const { exchange } = await pack()
13+
expectTypeOf(exchange.input.transport).toEqualTypeOf(Transport.memory)
14+
// @ts-expect-error any
15+
exchange.input.request
16+
const { unpack } = await exchange()
17+
expectTypeOf(unpack.input.transport).toEqualTypeOf(Transport.memory)
18+
// @ts-expect-error any
19+
unpack.input.response
20+
const { decode } = await unpack()
21+
expectTypeOf(decode.input.transport).toEqualTypeOf(Transport.memory)
22+
// @ts-expect-error any
23+
decode.input.response
24+
const result = await decode()
25+
if (!(result instanceof Error)) {
26+
// @ts-expect-error any
27+
result.response
28+
}
29+
return result
30+
})
31+
})
32+
33+
test(`cannot set headers in constructor`, () => {
34+
// todo: This error is poor for the user. It refers to schema not being a URL. The better message would be that headers is not allowed with memory transport.
35+
// @ts-expect-error headers not allowed with GraphQL schema
36+
Graffle.create({ schema, request: { headers: { 'x-foo': `bar` } } })
37+
})

0 commit comments

Comments
 (0)