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

Integrate graphql-http, refactor and comply with spec (v3) #1589

Closed
wants to merge 37 commits into from
Closed
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
dcef943
WIP
enisdenjo Aug 8, 2022
f3d4f14
more WIP
enisdenjo Aug 10, 2022
9f2b4bc
pass node spec
enisdenjo Aug 12, 2022
969f2be
errors in json body, spec compliant tests
enisdenjo Aug 12, 2022
2b8e6cb
use servercontext
enisdenjo Aug 12, 2022
f9da15c
typo
enisdenjo Aug 12, 2022
71881e2
sse is acceptable
enisdenjo Aug 12, 2022
9202f7d
use after ? as ssearch params
enisdenjo Aug 12, 2022
06ec6c2
better accepts and json multipart
enisdenjo Aug 12, 2022
06f4ddb
unnecessary imports
enisdenjo Aug 12, 2022
c14302c
tests tests tests
enisdenjo Aug 12, 2022
7f202b8
Merge branch 'v3' into v3-graphql-http
enisdenjo Aug 12, 2022
aab65d2
error handling and spec tests
enisdenjo Aug 12, 2022
62e817a
error masking tests
enisdenjo Aug 12, 2022
48af902
pass along empty query string for persisted queries
enisdenjo Aug 12, 2022
5e07f27
graphqlerrors client fault, all other server fault
enisdenjo Aug 12, 2022
f0220ca
simplify and centralise parsing
enisdenjo Aug 12, 2022
4ff16f9
respect graphql errors http extensions
enisdenjo Aug 12, 2022
84aadff
log
enisdenjo Aug 12, 2022
64d589c
internal error 500 only if on response hooks fail
enisdenjo Aug 12, 2022
03b9b21
drop unused import
enisdenjo Aug 12, 2022
76e770b
use published graphql-http
enisdenjo Aug 12, 2022
39fbe6f
test adjustments
enisdenjo Aug 12, 2022
e064869
Merge branch 'v3' into v3-graphql-http
enisdenjo Aug 16, 2022
96841e6
more acceptable
enisdenjo Aug 16, 2022
ac26e6a
multipart is enabled if not explicitly disabled
enisdenjo Aug 16, 2022
d909bab
bump graphql-http and application/graphql-response+json
enisdenjo Aug 16, 2022
352c572
fastify integration, multpartform request and TODO multipart response
enisdenjo Aug 16, 2022
1e9a658
request body can be function
enisdenjo Aug 16, 2022
d968a31
"null" is valid json but not an object
enisdenjo Aug 16, 2022
19cc9a8
use status 413 for file upload size limit exceeded
enisdenjo Aug 16, 2022
7e371db
application/graphql-response+json is the default
enisdenjo Aug 16, 2022
1dae527
incremntal delivery support
enisdenjo Aug 16, 2022
5e85f85
schema "might" be missing
enisdenjo Aug 16, 2022
547c356
bump graphql-http
enisdenjo Aug 16, 2022
2a9c48c
simpler body parser
enisdenjo Aug 17, 2022
db3aca3
Merge branch 'v3' into v3-graphql-http
enisdenjo Aug 17, 2022
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
4 changes: 0 additions & 4 deletions .husky/pre-commit

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ test('should succeeds introspection query', async () => {
const response = await worker.fetch(request)

expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toBe('application/json')
expect(response.headers.get('content-type')).toContain('application/json')
expect(await response.json()).toMatchObject({
data: {
__schema: {
Expand Down
4 changes: 3 additions & 1 deletion examples/express/__integration-tests__/express.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ function getTests(app: Express.Application) {
.post('/graphql')
.send({ query: '{ hello }' })
expect(response.statusCode).toBe(200)
expect(response.headers['content-type']).toContain('application/json')
expect(response.headers['content-type']).toContain(
'application/graphql+json',
)
expect(response.body).toStrictEqual({
data: {
hello: 'world',
Expand Down
8 changes: 6 additions & 2 deletions examples/koa/__integration-tests__/koa.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ describe('koa', () => {
.post('/graphql')
.send({ query: '{ hello }' })
expect(response.statusCode).toBe(200)
expect(response.headers['content-type']).toContain('application/json')
expect(response.headers['content-type']).toContain(
'application/graphql+json',
)
expect(response.body).toStrictEqual({
data: {
hello: 'world',
Expand All @@ -27,7 +29,9 @@ describe('koa', () => {
.post('/graphql')
.send({ query: '{ isKoa }' })
expect(response.statusCode).toBe(200)
expect(response.headers['content-type']).toContain('application/json')
expect(response.headers['content-type']).toContain(
'application/graphql+json',
)
expect(response.body).toStrictEqual({
data: {
isKoa: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ describe('Service worker', () => {
})

expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toBe('application/json')
expect(response.headers.get('content-type')).toContain('application/json')
expect(await response.json()).toMatchObject({
data: {
__schema: {
Expand Down
12 changes: 10 additions & 2 deletions packages/graphql-yoga/__tests__/accept-header.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,11 @@ describe('accept header', () => {
},
},
)
expect(response.status).toEqual(406)
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is per HTTP specification not GraphQL over HTTP, because this has nothing to do with GraphQL over HTTP since this validation is about the contract between client and server.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Even though, GraphQL over HTTP says we should return 406 in this case as I understand;
https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#body

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Because when accepting application/json the server should not use 4xx and 5xx (see spec).

Copy link
Collaborator

Choose a reason for hiding this comment

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

But we don't accept application/json, do we?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

We do accept it, or do you mean we don't accept it in this situation? The server is set up now to respect the accept headers, if the client requests application/json and makes an invalid operation - the server will error out respecting the accept requirements.

expect(response.status).toEqual(200)
const body = await response.json()
expect(body.errors).toEqual([
{ message: 'Subscriptions are not supported' },
Copy link
Collaborator

Choose a reason for hiding this comment

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

Message is not clear for me. It is not supported because Accept doesn't conform the response's content type. 406 explains this better.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Same as above, when accepting application/json the server should not use 4xx and 5xx (see spec).

Copy link
Collaborator

Choose a reason for hiding this comment

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

Same with above, we don't accept application/json in this case, so we should return Not Acceptable to the client. Also I think error message is too generic. It is hard to understand that the problem is the accepted mime type.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

#1589 (comment). I know that as per the HTTP spec 406 is obvious, but I am trying to follow the GQL over HTTP spec... I can improve the error message, it is indeed vague.

])
})

it('server rejects request for AsyncIterable source (defer/stream) when client only accepts application/json', async () => {
Expand Down Expand Up @@ -202,6 +206,10 @@ describe('accept header', () => {
accept: 'application/json',
},
})
expect(response.status).toEqual(406)
expect(response.status).toEqual(200)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Same as above, when accepting application/json the server should not use 4xx and 5xx (see spec).

const body = await response.json()
expect(body.errors).toEqual([
{ message: 'Subscriptions are not supported' },
])
})
})
63 changes: 30 additions & 33 deletions packages/graphql-yoga/__tests__/error-masking.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,12 @@ describe('error masking', () => {
}
})

it('non GraphQLError raised in onRequestParse is masked with the correct status code 500', async () => {
it('non GraphQLError raised in onPrepare is masked with the correct status code 500', async () => {
Copy link
Collaborator

@ardatan ardatan Aug 12, 2022

Choose a reason for hiding this comment

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

onPrepare looks too generic. onRequestParse is getting triggered after onRequest(request received) and when GraphQLParams is requested if any other plugin ended the response earlier.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

onPrepare does the "preparation" part, manipulates the params and accepts early results (for caching).

Name seemed like a right fit IMHO; but debatable, I agree.

const yoga = createYoga({
schema: createTestSchema(),
plugins: [
{
onRequestParse() {
onPrepare() {
throw new Error('Some random error!')
},
},
Expand All @@ -160,6 +160,9 @@ describe('error masking', () => {

const response = await yoga.fetch('http://yoga/graphql', {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({ query: '{ hi hello }' }),
})

Expand All @@ -186,15 +189,14 @@ describe('error masking', () => {
})
const body = JSON.parse(await response.text())
expect(body).toMatchInlineSnapshot(`
Object {
"data": null,
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think we should always have data even if it is null;
https://spec.graphql.org/draft/#sec-Response-Format

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Copy link
Collaborator

@ardatan ardatan Aug 13, 2022

Choose a reason for hiding this comment

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

If the request failed before execution, due to a syntax error, missing information, or validation error, this entry must not be present.

Also I found this one, which I think we should cover as well.

"errors": Array [
Object {
"message": "I like turtles",
},
],
}
`)
Object {
"errors": Array [
Object {
"message": "I like turtles",
},
],
}
`)
})

it('error thrown within context factory is masked', async () => {
Expand All @@ -213,15 +215,14 @@ describe('error masking', () => {
})
const body = JSON.parse(await response.text())
expect(body).toMatchInlineSnapshot(`
Object {
"data": null,
"errors": Array [
Object {
"message": "Unexpected error.",
},
],
}
`)
Object {
"errors": Array [
Object {
"message": "Unexpected error.",
},
],
}
`)
})

it('GraphQLError thrown within context factory with error masking is not masked', async () => {
Expand All @@ -240,15 +241,14 @@ describe('error masking', () => {
})
const body = JSON.parse(await response.text())
expect(body).toMatchInlineSnapshot(`
Object {
"data": null,
"errors": Array [
Object {
"message": "I like turtles",
},
],
}
`)
Object {
"errors": Array [
Object {
"message": "I like turtles",
},
],
}
`)
})

it('GraphQLError thrown within context factory has error extensions exposed on the response', async () => {
Expand Down Expand Up @@ -277,7 +277,6 @@ describe('error masking', () => {
})
const body = JSON.parse(await response.text())
expect(body).toStrictEqual({
data: null,
errors: [
{
message: 'I like turtles',
Expand All @@ -287,7 +286,7 @@ describe('error masking', () => {
},
],
})
expect(response.status).toEqual(200)
expect(response.status).toEqual(400)
Copy link
Collaborator

@ardatan ardatan Aug 12, 2022

Choose a reason for hiding this comment

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

Why 400? 4xx(Bad Request) should be thrown by the client's mistake, no?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I made a change to default to 200/400 when throwing instances of GraphQLError, and 200/500 when throwing instances of Error. The handler will still respect the http extensions (if not from resolvers).

See here:
https://github.com/enisdenjo/graphql-yoga/blob/39fbe6f575a0f7ba1a111bd4eb54da5b1ea074d2/packages/graphql-yoga/src/server.ts#L454-L476

})

it('parse error is not masked', async () => {
Expand All @@ -314,7 +313,6 @@ describe('error masking', () => {
const body = JSON.parse(await response.text())

expect(body).toEqual({
data: null,
errors: [
{
locations: [
Expand Down Expand Up @@ -353,7 +351,6 @@ describe('error masking', () => {
const body = JSON.parse(await response.text())

expect(body).toEqual({
data: null,
errors: [
{
locations: [
Expand Down
151 changes: 14 additions & 137 deletions packages/graphql-yoga/__tests__/http-extensions.spec.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,25 @@
import { GraphQLError } from 'graphql'
import { createSchema, createYoga } from 'graphql-yoga'
import { createYoga } from 'graphql-yoga'

describe('GraphQLError.extensions.http', () => {
it('sets correct status code and headers for thrown GraphQLError in a resolver', async () => {
it('sets correct status code and headers for thrown GraphQLError in plugins', async () => {
const yoga = createYoga({
schema: createSchema({
typeDefs: /* GraphQL */ `
type Query {
a: String
}
`,
resolvers: {
Query: {
a() {
throw new GraphQLError('A', {
extensions: {
http: {
status: 401,
headers: {
'www-authenticate': 'Bearer',
},
plugins: [
{
onPrepare() {
throw new GraphQLError('A', {
extensions: {
http: {
status: 401,
headers: {
'www-authenticate': 'Bearer',
},
},
})
},
},
})
},
},
}),
],
logging: false,
})

Expand All @@ -39,120 +32,4 @@ describe('GraphQLError.extensions.http', () => {
expect(response.status).toBe(401)
expect(response.headers.get('www-authenticate')).toBe('Bearer')
})

it('picks the highest status code and headers for GraphQLErrors thrown within multiple resolvers', async () => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

We have this because we want to give the user to control the response parameters. Also we benefit from this in our plugins in the execution level.

Copy link
Collaborator Author

@enisdenjo enisdenjo Aug 12, 2022

Choose a reason for hiding this comment

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

Throwing errors from field resolvers will nullify only that field, for example:

query { a }

and resolver for a errors out, the response will be:

{ data: { a: null }, errors: [{ message: 'Woah!' }] }

As per the spec, if the data field is not-null, the response code must be 200. (For accepting application/json and for accepting application/graphql-response+json)

const yoga = createYoga({
schema: {
typeDefs: /* GraphQL */ `
type Query {
a: String
b: String
}
`,
resolvers: {
Query: {
a: () => {
throw new GraphQLError('A', {
extensions: {
http: {
status: 401,
},
},
})
},
b: () => {
throw new GraphQLError('B', {
extensions: {
http: {
status: 503,
},
},
})
},
},
},
},
})

const response = await yoga.fetch('http://yoga/graphql', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ query: '{ a b }' }),
})

expect(response.status).toBe(503)

const body = JSON.parse(await response.text())
expect(body).toMatchObject({
data: {
a: null,
b: null,
},
errors: [
{
message: 'A',
path: ['a'],
},
{
message: 'B',
path: ['b'],
},
],
})
})

it('picks the header of the last GraphQLError when multiple errors are thrown within multiple resolvers', async () => {
const yoga = createYoga({
schema: {
typeDefs: /* GraphQL */ `
type Query {
a: String
b: String
}
`,
resolvers: {
Query: {
a: () => {
throw new GraphQLError('', {
extensions: {
http: {
headers: {
'x-foo': 'A',
},
},
},
})
},
b: () => {
throw new GraphQLError('DB is not available', {
extensions: {
http: {
headers: {
'x-foo': 'B',
},
},
},
})
},
},
},
},
})

let response = await yoga.fetch('http://yoga/graphql', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ query: '{ a b }' }),
})

expect(response.headers.get('x-foo')).toBe('B')

response = await yoga.fetch('http://yoga/graphql', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ query: '{ b a }' }),
})

expect(response.headers.get('x-foo')).toBe('A')
})
})
6 changes: 4 additions & 2 deletions packages/graphql-yoga/__tests__/introspection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,11 @@ describe('introspection', () => {
})

expect(response.status).toBe(400)
expect(response.headers.get('content-type')).toBe('application/json')
expect(response.headers.get('content-type')).toBe(
'application/graphql+json; charset=utf-8',
)
const body = await response.json()
expect(body.data).toBeNull()
expect(body.data).not.toBeDefined()
expect(body.errors![0]).toMatchInlineSnapshot(`
Object {
"locations": Array [
Expand Down
Loading