Skip to content

Commit

Permalink
fix: spec compliant accept type (#1064)
Browse files Browse the repository at this point in the history
  • Loading branch information
jasonkuhrt committed Sep 5, 2024
1 parent bb48aee commit f5a6476
Show file tree
Hide file tree
Showing 15 changed files with 731 additions and 877 deletions.
6 changes: 3 additions & 3 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ import tsEslint from 'typescript-eslint'

export default tsEslint.config({
ignores: [
'**/build/**/*',
'eslint.config.js',
'vite.config.ts',
'**/generated/**/*',
'**/$generated-clients/**/*',
'**/website/**/*',
'**/website/.vitepress/**/*',
'legacy/**/*',
'build/**/*',
'website/**/*',
],
extends: configPrisma,
languageOptions: {
Expand Down
6 changes: 3 additions & 3 deletions examples/transport-http_RequestInput.output.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
body: '{"query":"{ languages { code } }"}',
method: 'POST',
headers: Headers {
authorization: 'Bearer MY_TOKEN',
accept: 'application/graphql-response+json',
'content-type': 'application/json'
accept: 'application/graphql-response+json; charset=utf-8, application/json; charset=utf-8',
'content-type': 'application/json',
authorization: 'Bearer MY_TOKEN'
},
mode: 'cors'
}
10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,12 @@
"@types/body-parser": "^1.19.5",
"@types/express": "^4.17.21",
"@types/json-bigint": "^1.0.4",
"@types/node": "^22.5.1",
"@typescript-eslint/eslint-plugin": "^8.3.0",
"@typescript-eslint/parser": "^8.3.0",
"@types/node": "^22.5.4",
"@typescript-eslint/eslint-plugin": "^8.4.0",
"@typescript-eslint/parser": "^8.4.0",
"doctoc": "^2.2.1",
"dripip": "^0.10.0",
"es-toolkit": "^1.16.0",
"es-toolkit": "^1.17.0",
"eslint": "^9.9.1",
"eslint-config-prisma": "^0.6.0",
"eslint-plugin-deprecation": "^3.0.0",
Expand All @@ -143,7 +143,7 @@
"tsx": "^4.19.0",
"type-fest": "^4.26.0",
"typescript": "^5.5.4",
"typescript-eslint": "^8.3.0",
"typescript-eslint": "^8.4.0",
"vitepress": "^1.3.4",
"vitest": "^2.0.5"
}
Expand Down
537 changes: 246 additions & 291 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

31 changes: 18 additions & 13 deletions src/layers/5_core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ import type { DocumentNode, ExecutionResult, GraphQLSchema } from 'graphql'
import { print } from 'graphql'
import { Anyware } from '../../lib/anyware/__.js'
import { type StandardScalarVariables } from '../../lib/graphql.js'
import { CONTENT_TYPE_GQL_OVER_HTTP_REC, parseExecutionResult } from '../../lib/graphqlHTTP.js'
import { CONTENT_TYPE_JSON, mergeHeadersInit } from '../../lib/http.js'
import { ACCEPT_REC, CONTENT_TYPE_REC, parseExecutionResult } from '../../lib/graphqlHTTP.js'
import { casesExhausted } from '../../lib/prelude.js'
import { execute } from '../0_functions/execute.js'
import type { Schema } from '../1_Schema/__.js'
Expand Down Expand Up @@ -213,16 +212,20 @@ export const anyware = Anyware.create<HookSequence, HookMap, ExecutionResult>({
body: input.body,
// @see https://graphql.github.io/graphql-over-http/draft/#sec-POST
method: `POST`,
...mergeRequestInputOptions(input.context.config.requestInputOptions, {
headers: mergeHeadersInit(input.headers, {
accept: CONTENT_TYPE_GQL_OVER_HTTP_REC,
// todo if body is something else, say upload extension turns it into a FormData, then fetch will automatically set the content-type header.
// ... 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?
...(typeof input.body === `string`
? { 'content-type': CONTENT_TYPE_JSON }
: {}),
}),
}),
...mergeRequestInputOptions(
mergeRequestInputOptions(
{
headers: {
accept: ACCEPT_REC,
'content-type': CONTENT_TYPE_REC,
},
},
input.context.config.requestInputOptions,
),
{
headers: input.headers,
},
),
}
return {
...input,
Expand All @@ -243,7 +246,9 @@ export const anyware = Anyware.create<HookSequence, HookMap, ExecutionResult>({
switch (input.transport) {
case `http`: {
const request = new Request(input.request.url, input.request)
// console.log(request)
const response = await slots.fetch(request)
// console.log(response)
return {
...input,
response,
Expand All @@ -270,7 +275,7 @@ export const anyware = Anyware.create<HookSequence, HookMap, ExecutionResult>({
switch (input.transport) {
case `http`: {
// todo 1 if response is missing header of content length then .json() hangs forever.
// todo 1 firstly consider a timeout, secondly, if response is malformed, then don't even run .json()
// firstly consider a timeout, secondly, if response is malformed, then don't even run .json()
// 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
const json = await input.response.json() as object
const result = parseExecutionResult(json)
Expand Down
104 changes: 0 additions & 104 deletions src/layers/6_client/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,6 @@ import { createResponse, test } from '../../../tests/_/helpers.js'
import { Graffle as Graffle2 } from '../../../tests/_/schema/generated/__.js'
import { schema } from '../../../tests/_/schema/schema.js'
import { Graffle } from '../../entrypoints/main.js'
import { CONTENT_TYPE_GQL, CONTENT_TYPE_JSON } from '../../lib/http.js'
import { Transport } from '../5_core/types.js'
import type { RequestInput } from './Settings/inputIncrementable/request.js'

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

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

describe(`transport`, () => {
describe(`http`, () => {
test(`anyware hooks are typed to http transport`, () => {
Graffle.create({ schema: endpoint }).use(async ({ encode }) => {
expectTypeOf(encode.input.transport).toEqualTypeOf(Transport.http)
const { pack } = await encode()
expectTypeOf(pack.input.transport).toEqualTypeOf(Transport.http)
const { exchange } = await pack()
expectTypeOf(exchange.input.transport).toEqualTypeOf(Transport.http)
expectTypeOf(exchange.input.request).toEqualTypeOf<RequestInput>()
const { unpack } = await exchange()
expectTypeOf(unpack.input.transport).toEqualTypeOf(Transport.http)
expectTypeOf(unpack.input.response).toEqualTypeOf<Response>()
const { decode } = await unpack()
expectTypeOf(decode.input.transport).toEqualTypeOf(Transport.http)
expectTypeOf(decode.input.response).toEqualTypeOf<Response>()
const result = await decode()
if (!(result instanceof Error)) {
expectTypeOf(result.response).toEqualTypeOf<Response>()
}
return result
})
})
test(`can set headers in constructor`, async ({ fetch }) => {
fetch.mockImplementationOnce(() => Promise.resolve(createResponse({ data: { id: `abc` } })))
const graffle = Graffle.create({ schema: endpoint, request: { headers: { 'x-foo': `bar` } } })
await graffle.rawString({ document: `query { id }` })
const request = fetch.mock.calls[0]?.[0]
expect(request?.headers.get(`x-foo`)).toEqual(`bar`)
})
test(`sends spec compliant request`, async ({ fetch, graffle }) => {
fetch.mockImplementationOnce(() => Promise.resolve(createResponse({ data: { greetings: `Hello World` } })))
await graffle.rawString({ document: `query { greetings }` })
const request = fetch.mock.calls[0]?.[0]
expect(request?.headers.get(`content-type`)).toEqual(CONTENT_TYPE_JSON)
expect(request?.headers.get(`accept`)).toEqual(CONTENT_TYPE_GQL)
})
describe(`signal`, () => {
// JSDom and Node result in different errors. JSDom is a plain Error type. Presumably an artifact of JSDom and now in actual browsers.
const abortErrorMessagePattern = /This operation was aborted|AbortError: The operation was aborted/
test(`AbortController at instance level works`, async () => {
const abortController = new AbortController()
const graffle = Graffle.create({
schema: endpoint,
request: { signal: abortController.signal },
})
const resultPromise = graffle.rawString({ document: `query { id }` })
abortController.abort()
const { caughtError } = await resultPromise.catch((caughtError: unknown) => ({ caughtError })) as any as {
caughtError: Error
}
expect(caughtError.message).toMatch(abortErrorMessagePattern)
})
test(`AbortController at method level works`, async () => {
const abortController = new AbortController()
const graffle = Graffle.create({
schema: endpoint,
}).with({ request: { signal: abortController.signal } })
const resultPromise = graffle.rawString({ document: `query { id }` })
abortController.abort()
const { caughtError } = await resultPromise.catch((caughtError: unknown) => ({ caughtError })) as any as {
caughtError: Error
}
expect(caughtError.message).toMatch(abortErrorMessagePattern)
})
})
})
describe(`memory`, () => {
test(`anyware hooks are typed to memory transport`, () => {
Graffle.create({ schema }).use(async ({ encode }) => {
expectTypeOf(encode.input.transport).toEqualTypeOf(Transport.memory)
const { pack } = await encode()
expectTypeOf(pack.input.transport).toEqualTypeOf(Transport.memory)
const { exchange } = await pack()
expectTypeOf(exchange.input.transport).toEqualTypeOf(Transport.memory)
// @ts-expect-error any
exchange.input.request
const { unpack } = await exchange()
expectTypeOf(unpack.input.transport).toEqualTypeOf(Transport.memory)
// @ts-expect-error any
unpack.input.response
const { decode } = await unpack()
expectTypeOf(decode.input.transport).toEqualTypeOf(Transport.memory)
// @ts-expect-error any
decode.input.response
const result = await decode()
if (!(result instanceof Error)) {
// @ts-expect-error any
result.response
}
return result
})
})
test(`cannot set headers in constructor`, () => {
// 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.
// @ts-expect-error headers not allowed with GraphQL schema
Graffle.create({ schema, request: { headers: { 'x-foo': `bar` } } })
})
})
})

describe(`output`, () => {
test(`when using envelope and transport is http, response property is available`, async ({ fetch }) => {
fetch.mockImplementationOnce(() => Promise.resolve(createResponse({ data: { id: `abc` } })))
Expand Down
76 changes: 76 additions & 0 deletions src/layers/6_client/client.transport-http.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, expect, expectTypeOf } from 'vitest'
import { createResponse, test } from '../../../tests/_/helpers.js'
import { Graffle } from '../../entrypoints/main.js'
import { ACCEPT_REC, CONTENT_TYPE_REC } from '../../lib/graphqlHTTP.js'
import { Transport } from '../5_core/types.js'
import type { RequestInput } from './Settings/inputIncrementable/request.js'

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

test(`anyware hooks are typed to http transport`, () => {
Graffle.create({ schema: endpoint }).use(async ({ encode }) => {
expectTypeOf(encode.input.transport).toEqualTypeOf(Transport.http)
const { pack } = await encode()
expectTypeOf(pack.input.transport).toEqualTypeOf(Transport.http)
const { exchange } = await pack()
expectTypeOf(exchange.input.transport).toEqualTypeOf(Transport.http)
expectTypeOf(exchange.input.request).toEqualTypeOf<RequestInput>()
const { unpack } = await exchange()
expectTypeOf(unpack.input.transport).toEqualTypeOf(Transport.http)
expectTypeOf(unpack.input.response).toEqualTypeOf<Response>()
const { decode } = await unpack()
expectTypeOf(decode.input.transport).toEqualTypeOf(Transport.http)
expectTypeOf(decode.input.response).toEqualTypeOf<Response>()
const result = await decode()
if (!(result instanceof Error)) {
expectTypeOf(result.response).toEqualTypeOf<Response>()
}
return result
})
})

test(`can set headers in constructor`, async ({ fetch }) => {
fetch.mockImplementationOnce(() => Promise.resolve(createResponse({ data: { id: `abc` } })))
const graffle = Graffle.create({ schema: endpoint, request: { headers: { 'x-foo': `bar` } } })
await graffle.rawString({ document: `query { id }` })
const request = fetch.mock.calls[0]?.[0]
expect(request?.headers.get(`x-foo`)).toEqual(`bar`)
})

test(`sends spec compliant request`, async ({ fetch, graffle }) => {
fetch.mockImplementationOnce(() => Promise.resolve(createResponse({ data: { greetings: `Hello World` } })))
await graffle.rawString({ document: `query { greetings }` })
const request = fetch.mock.calls[0]?.[0]
expect(request?.headers.get(`content-type`)).toEqual(CONTENT_TYPE_REC)
expect(request?.headers.get(`accept`)).toEqual(ACCEPT_REC)
})

describe(`signal`, () => {
// JSDom and Node result in different errors. JSDom is a plain Error type. Presumably an artifact of JSDom and now in actual browsers.
const abortErrorMessagePattern = /This operation was aborted|AbortError: The operation was aborted/
test(`AbortController at instance level works`, async () => {
const abortController = new AbortController()
const graffle = Graffle.create({
schema: endpoint,
request: { signal: abortController.signal },
})
const resultPromise = graffle.rawString({ document: `query { id }` })
abortController.abort()
const { caughtError } = await resultPromise.catch((caughtError: unknown) => ({ caughtError })) as any as {
caughtError: Error
}
expect(caughtError.message).toMatch(abortErrorMessagePattern)
})
test(`AbortController at method level works`, async () => {
const abortController = new AbortController()
const graffle = Graffle.create({
schema: endpoint,
}).with({ request: { signal: abortController.signal } })
const resultPromise = graffle.rawString({ document: `query { id }` })
abortController.abort()
const { caughtError } = await resultPromise.catch((caughtError: unknown) => ({ caughtError })) as any as {
caughtError: Error
}
expect(caughtError.message).toMatch(abortErrorMessagePattern)
})
})
37 changes: 37 additions & 0 deletions src/layers/6_client/client.transport-memory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { expectTypeOf } from 'vitest'
import { test } from '../../../tests/_/helpers.js'
import { schema } from '../../../tests/_/schema/schema.js'
import { Graffle } from '../../entrypoints/main.js'
import { Transport } from '../5_core/types.js'

test(`anyware hooks are typed to memory transport`, () => {
Graffle.create({ schema }).use(async ({ encode }) => {
expectTypeOf(encode.input.transport).toEqualTypeOf(Transport.memory)
const { pack } = await encode()
expectTypeOf(pack.input.transport).toEqualTypeOf(Transport.memory)
const { exchange } = await pack()
expectTypeOf(exchange.input.transport).toEqualTypeOf(Transport.memory)
// @ts-expect-error any
exchange.input.request
const { unpack } = await exchange()
expectTypeOf(unpack.input.transport).toEqualTypeOf(Transport.memory)
// @ts-expect-error any
unpack.input.response
const { decode } = await unpack()
expectTypeOf(decode.input.transport).toEqualTypeOf(Transport.memory)
// @ts-expect-error any
decode.input.response
const result = await decode()
if (!(result instanceof Error)) {
// @ts-expect-error any
result.response
}
return result
})
})

test(`cannot set headers in constructor`, () => {
// 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.
// @ts-expect-error headers not allowed with GraphQL schema
Graffle.create({ schema, request: { headers: { 'x-foo': `bar` } } })
})
Loading

0 comments on commit f5a6476

Please sign in to comment.