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

Conversation

enisdenjo
Copy link
Collaborator

@enisdenjo enisdenjo commented Aug 12, 2022

This PR encapsulates graphql-http integration, adjustments to comply with the spec and my take on graphql-yoga following my investigation and current understanding.

Please note that this PR is a proposal and would love to discuss this and adjust accordingly!

Key takeways:

@changeset-bot
Copy link

changeset-bot bot commented Aug 12, 2022

⚠️ No Changeset found

Latest commit: db3aca3

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Copy link
Collaborator

@ardatan ardatan left a comment

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.
Sorry I've put this comment in a wrong place. I can write here something else :D
Why do we stop supporting multipart defer/stream and event stream responses. I think we are missing a lot of points of using plugins(modularized structure) and hooks.

@enisdenjo
Copy link
Collaborator Author

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.

@ardatan I don't follow, which validation?

@enisdenjo enisdenjo marked this pull request as draft August 12, 2022 15:40
@@ -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.

@@ -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).

@@ -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.

@@ -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.

@@ -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

@ardatan
Copy link
Collaborator

ardatan commented Aug 12, 2022

I still think a generic "plain" GraphQL over http server can already be created by using the parts deleted here with a few lines code.
If we need that kind of version of Yoga, we can basically move all those request parser + validaiton + processors into another treeshakable package then import it in both current Yoga package and we can have a section on our docs like you can basically create your own GraphQL over HTTP server like below;

import { DocumentNode, execute, ExecutionArgs, GraphQLSchema, parse, validate } from "graphql";
import { assertMutationViaGet, assertValidGraphQLParams, GraphQLParams, isGETRequest, isPOSTGraphQLStringRequest, isPOSTJsonRequest, isRegularResult, parseGETRequest, parsePOSTGraphQLStringRequest, parsePOSTJsonRequest } from 'graphql-yoga'; // Or any other core package we would introduce in this case

export async function handleGraphQLoverHTTPRequest(
  request: Request,
  schema: GraphQLSchema,
): Promise<Response> {

  let params: GraphQLParams | undefined;
  if (isGETRequest(request)) {
    params = parseGETRequest(request);
  }
  if (isPOSTJsonRequest(request)) {
    params = await parsePOSTJsonRequest(request);
  }
  if (isPOSTGraphQLStringRequest(request)) {
    params = await parsePOSTGraphQLStringRequest(request);
  }

  try {
    assertValidGraphQLParams(params);
  } catch (e) {
    return new Response(JSON.stringify(e), {
      status: e.extensions.http.status,
      headers: e.extensions.http.headers,
    })
  }

  let document: DocumentNode;
  try {
    document = parse(params.query!);
  } catch (e) {
    return new Response(JSON.stringify(e), {
      status: 400,
    })
  }

  try {
    assertMutationViaGet(request.method, document, params.operationName);
  } catch (e) {
    return new Response(JSON.stringify(e), {
      status: e.extensions.http.status,
      headers: e.extensions.http.headers,
    })
  }

  const errors = validate(schema, document);

  if (errors?.length > 0) {
    return new Response(JSON.stringify({ errors }), {
      status: 400,
      headers: {
        'Content-Type': 'application/json',
      }
    });
  }

  const executionArgs: ExecutionArgs = {
    schema,
    document,
    contextValue: {
      request,
    },
    variableValues: params.variables,
    operationName: params.operationName,
  };

  const result = await execute(executionArgs);

  if (isRegularResult(request, result)) {
    return new Response(JSON.stringify(result), {
      headers: {
        'Content-Type': 'application/json',
      }
    });
  }

  return new Response('Not implemented', {
    status: 500,
  })
}

@@ -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)

@@ -57,6 +58,7 @@ describe('Automatic Persisted Queries', () => {
const response = await request(yoga)
.post('/graphql')
.send({
query: '',
Copy link
Collaborator

Choose a reason for hiding this comment

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

I don't think clients supporting APQ provide query. If they don't, this would break the support of those clients.

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.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yes but this is a different spec we follow. We are going beyond spec here and we want to support clients instead of introducing our own.

Copy link

Choose a reason for hiding this comment

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

Note to be spec compliant query must not only be present but also must be the string representation of the GraphQL document. An empty string does not satisfy the spec (and is weird/confusing).

Persisted operations are deliberately not covered by the GraphQL-over-HTTP spec right now, this is because the spec is to increase interoperability but persisted operations (when used as an allowlist) have exactly the opposite aim. That’s fine - you can use persisted operations alongside a GraphQL compliant server, it’s just an alternative communication method (not spec compliant, but still perfectly fine). We’ll probably incorporate some wording for persisted operations in a post-1.0 release of the spec.

) => PromiseOrValue<void>

export interface OnRequestParseDoneEventPayload {
export interface OnPrepareEventPayload {
Copy link
Collaborator

Choose a reason for hiding this comment

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

onRequestParse and onRequestParseDone hooks are seperated because subsequent plugins can change the way of parsing before parsing has been done by Yoga's request parsers, so requestParser isn't called in here yet.
onRequestParseDone is called after the parameters are parsed in order to let the user change the parsed params for the execution. For example, persisted operations use this to manipulate the parameters for the following execution.

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 understand that, my idea was to not have the user control the parsing. Yoga should implement all official and unofficial parsing techniques and do the parsing for the user, and have him manipulate the results only.

But this is debatable...

@enisdenjo
Copy link
Collaborator Author

Why do we stop supporting multipart defer/stream and event stream responses. I think we are missing a lot of points of using plugins(modularized structure) and hooks.

@ardatan not my intention to stop supporting them, bugs are failing the integration tests (I've been running yarn test). Will fix them. 👍

I still think a generic "plain" GraphQL over http server can already be created by using the parts deleted here with a few lines code.
If we need that kind of version of Yoga, we can basically move all those request parser + validaiton + processors into another treeshakable package then import it in both current Yoga package and we can have a section on our docs like you can basically create your own GraphQL over HTTP server like below;

@ardatan I follow. I just am of the opinion that users won't need that kind of granularity, I think the benefit of Yoga is not to do any heavy lifting - just plug-n-play.

Furthermore, your example is not spec compliant in terms of the accepted content-type dictating the status codes. It would be different depending on whether the server accepted application/json vs. application/graphql-json.

@enisdenjo
Copy link
Collaborator Author

Now with all tests passing (that are passing on v3 branch).

# Conflicts:
#	packages/graphql-yoga/src/plugins/requestValidation/usePreventMutationViaGET.ts
@theguild-bot theguild-bot mentioned this pull request Aug 20, 2022
@enisdenjo
Copy link
Collaborator Author

Closing in favour of #1685. Might revisit sometimes in the future, but not soon.

@enisdenjo enisdenjo closed this Sep 20, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants