Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: spec compliant accept type #1064

Merged
merged 5 commits into from
Sep 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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