Skip to content

Commit

Permalink
W3 Compatible Response handlers (#89)
Browse files Browse the repository at this point in the history
  • Loading branch information
ardatan committed Nov 3, 2021
1 parent db559a2 commit 1ada48e
Show file tree
Hide file tree
Showing 13 changed files with 1,320 additions and 838 deletions.
5 changes: 5 additions & 0 deletions .changeset/witty-hornets-relax.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"graphql-helix": minor
---

feat: W3C Response handlers
1 change: 1 addition & 0 deletions packages/core/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export * from "./should-render-graphiql";
export * from "./types";
export * from "./errors";
export * from "./send-result/node-http";
export * from "./send-result/w3c";
7 changes: 2 additions & 5 deletions packages/core/lib/send-result/node-http.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
// @denoify-ignore
import { ExecutionResult } from "graphql";
import type { ServerResponse } from "http";
import type { Http2ServerResponse } from "http2";
import { HttpError } from "../errors";
import type { Response, MultipartResponse, Push, ProcessRequestResult } from "../types";
import { TransformResultFn, DEFAULT_TRANSFORM_RESULT_FN } from "./utils";

export type RawResponse = ServerResponse | Http2ServerResponse;
export type TransformResultFn = (result: ExecutionResult) => ExecutionResult;

const DEFAULT_TRANSFORM_RESULT_FN: TransformResultFn = (result) => result;

export async function sendResponseResult(
responseResult: Response<any, any>,
Expand Down Expand Up @@ -76,7 +73,7 @@ export async function sendPushResult(

await pushResult.subscribe((result) => {
// @ts-expect-error - Different Signature between ServerResponse and Http2ServerResponse but still compatible.
rawResponse.write(`data: ${JSON.stringify(transformResult(result))}\n\n`);
rawResponse.write(`data: ${JSON.stringify(transformResult(result))}\n\n`);
});
rawResponse.end();
}
Expand Down
4 changes: 4 additions & 0 deletions packages/core/lib/send-result/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { ExecutionResult } from "graphql";

export type TransformResultFn = (result: ExecutionResult) => any;
export const DEFAULT_TRANSFORM_RESULT_FN: TransformResultFn = (result: ExecutionResult) => result;
100 changes: 100 additions & 0 deletions packages/core/lib/send-result/w3c.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { HttpError } from "../errors";
import type { MultipartResponse, ProcessRequestResult, Push, Response as HelixResponse } from "../types";
import { DEFAULT_TRANSFORM_RESULT_FN } from "./utils";

export function getRegularResponse<TResponse extends Response>(
responseResult: HelixResponse<any, any>,
Response: { new(body: BodyInit, responseInit: ResponseInit): TResponse },
transformResult = DEFAULT_TRANSFORM_RESULT_FN,
): TResponse {
const headersInit: HeadersInit = [];
for (const { name, value } of responseResult.headers) {
headersInit.push([name, value]);
}
const responseInit: ResponseInit = {
headers: headersInit,
status: responseResult.status,
};
const transformedResult = transformResult(responseResult.payload);
const responseBody = JSON.stringify(transformedResult);
return new Response(responseBody, responseInit);
}

export function getMultipartResponse<TResponse extends Response, TReadableStream extends ReadableStream>(
multipartResult: MultipartResponse<any, any>,
Response: { new(readableStream: TReadableStream, responseInit: ResponseInit): TResponse },
ReadableStream: { new(underlyingSource: UnderlyingSource): TReadableStream },
transformResult = DEFAULT_TRANSFORM_RESULT_FN,
): TResponse {
const headersInit: HeadersInit = {
"Connection": "keep-alive",
"Content-Type": 'multipart/mixed; boundary="-"',
"Transfer-Encoding": "chunked",
};
const responseInit: ResponseInit = {
headers: headersInit,
status: 200,
};
const readableStream = new ReadableStream({
async start(controller) {
controller.enqueue(`---`);
await multipartResult.subscribe(patchResult => {
const transformedResult = transformResult(patchResult);
const chunk = JSON.stringify(transformResult(transformedResult));
const data = ["", "Content-Type: application/json; charset=utf-8", "Content-Length: " + String(chunk.length), "", chunk];
if (patchResult.hasNext) {
data.push("---");
}
controller.enqueue(data.join("\r\n"));
})
controller.enqueue('\r\n-----\r\n');
controller.close();
}
});
return new Response(readableStream, responseInit);
}

export function getPushResponse<TResponse extends Response, TReadableStream extends ReadableStream>(
pushResult: Push<any, any>,
Response: { new(readableStream: TReadableStream, responseInit: ResponseInit): TResponse },
ReadableStream: { new(underlyingSource: UnderlyingSource): TReadableStream },
transformResult = DEFAULT_TRANSFORM_RESULT_FN,
): TResponse {
const headersInit: HeadersInit = {
"Content-Type": "text/event-stream",
"Connection": "keep-alive",
"Cache-Control": "no-cache",
};
const responseInit: ResponseInit = {
headers: headersInit,
status: 200,
};

const readableStream = new ReadableStream({
async start(controller) {
await pushResult.subscribe(result => {
controller.enqueue(`data: ${JSON.stringify(transformResult(result))}\n\n`);
})
controller.close();
}
});
return new Response(readableStream, responseInit);
}

export function getResponse<TResponse extends Response, TReadableStream extends ReadableStream>(
result: ProcessRequestResult<any, any>,
Response: { new(body: BodyInit, responseInit: ResponseInit): TResponse },
ReadableStream: { new(underlyingSource: UnderlyingSource): TReadableStream },
transformResult = DEFAULT_TRANSFORM_RESULT_FN,
): TResponse {
switch (result.type) {
case "RESPONSE":
return getRegularResponse(result, Response, transformResult);
case "MULTIPART_RESPONSE":
return getMultipartResponse(result, Response, ReadableStream, transformResult);
case "PUSH":
return getPushResponse(result, Response, ReadableStream, transformResult);
default:
throw new HttpError(500, "Cannot process result.");
}
}
4 changes: 3 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"@types/chance": "1.1.3",
"@types/eventsource": "1.1.6",
"@types/jest": "27.0.2",
"@graphql-tools/schema": "8.3.1",
"chance": "1.1.8",
"chalk": "4.1.2",
"cpy-cli": "3.1.1",
Expand All @@ -59,7 +60,8 @@
"replacestream": "4.0.3",
"ts-jest": "27.0.5",
"ts-node": "10.2.1",
"typescript": "4.4.4"
"typescript": "4.4.4",
"undici": "4.9.3"
},
"peerDependencies": {
"graphql": "^15.3.0 || ^16.0.0"
Expand Down
191 changes: 191 additions & 0 deletions packages/core/test/w3c.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { Request, Response } from "undici";
import { makeExecutableSchema } from "@graphql-tools/schema";
import { getGraphQLParameters, processRequest, getResponse } from "../lib";
import { ReadableStream } from "stream/web";
import { parse as qsParse, stringify as qsStringify } from "qs";

declare module "stream/web" {
export const ReadableStream: any;
}

const schema = makeExecutableSchema({
typeDefs: /* GraphQL */ `
type Query {
hello: String
slowHello: String
}
type Subscription {
countdown(from: Int): Int
}
`,
resolvers: {
Query: {
hello: () => "world",
slowHello: () => new Promise((resolve) => setTimeout(() => resolve("world"), 300)),
},
Subscription: {
countdown: {
subscribe: async function* () {
for (let i = 3; i >= 0; i--) {
yield i;
}
},
resolve: (payload) => payload,
},
},
},
});

async function prepareHelixRequestFromW3CRequest(request: Request) {
const queryString = request.url.split("?")[1];
return {
body: request.method === "POST" && (await request.json()),
headers: request.headers,
method: request.method,
query: queryString && qsParse(queryString),
};
}

describe("W3 Compatibility", () => {
it("should handle regular POST request and responses", async () => {
const request = new Request("http://localhost:3000/graphql", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
query: "{ hello }",
}),
});
const helixRequest = await prepareHelixRequestFromW3CRequest(request);

const { operationName, query, variables } = getGraphQLParameters(helixRequest);

const result = await processRequest({
operationName,
query,
variables,
request: helixRequest,
schema,
});

const response = getResponse(result, Response as any, ReadableStream);
const responseJson = await response.json();
expect(responseJson).toEqual({
data: {
hello: "world",
},
});
});
it("should handle regular GET request and responses", async () => {
const request = new Request(
"http://localhost:3000/graphql?" +
qsStringify({
query: "{ hello }",
}),
{
method: "GET",
}
);
const helixRequest = await prepareHelixRequestFromW3CRequest(request);

const { operationName, query, variables } = getGraphQLParameters(helixRequest);

const result = await processRequest({
operationName,
query,
variables,
request: helixRequest,
schema,
});

const response = getResponse(result, Response as any, ReadableStream);
const responseJson = await response.json();
expect(responseJson).toEqual({
data: {
hello: "world",
},
});
});
it("should handle push responses", async () => {
const request = new Request("http://localhost:3000/graphql", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
query: "subscription Countdown($from: Int!) { countdown(from: $from) }",
variables: {
from: 3,
},
}),
});
const helixRequest = await prepareHelixRequestFromW3CRequest(request);

const { operationName, query, variables } = getGraphQLParameters(helixRequest);

const result = await processRequest({
operationName,
query,
variables,
request: helixRequest,
schema,
});

const response = getResponse(result, Response as any, ReadableStream);
const finalText = await response.text();
expect(finalText).toMatchInlineSnapshot(`
"data: {\\"data\\":{\\"countdown\\":3}}
data: {\\"data\\":{\\"countdown\\":2}}
data: {\\"data\\":{\\"countdown\\":1}}
data: {\\"data\\":{\\"countdown\\":0}}
"
`);
});
it("should handle multipart responses", async () => {
const request = new Request("http://localhost:3000/graphql", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
query: "{ ... on Query @defer { slowHello } hello }",
variables: {
from: 3,
},
}),
});
const helixRequest = await prepareHelixRequestFromW3CRequest(request);

const { operationName, query, variables } = getGraphQLParameters(helixRequest);

const result = await processRequest({
operationName,
query,
variables,
request: helixRequest,
schema,
});

const response = getResponse(result, Response as any, ReadableStream);
const finalText = await response.text();
expect(finalText).toMatchInlineSnapshot(`
"---
Content-Type: application/json; charset=utf-8
Content-Length: 41
{\\"data\\":{\\"hello\\":\\"world\\"},\\"hasNext\\":true}
---
Content-Type: application/json; charset=utf-8
Content-Length: 56
{\\"data\\":{\\"slowHello\\":\\"world\\"},\\"path\\":[],\\"hasNext\\":false}
-----
"
`);
});
});
2 changes: 1 addition & 1 deletion packages/core/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,5 @@
"node_modules",
"packages/graphql-helix/test"
],
"include": ["lib"]
"include": ["lib", "declarations.d.ts"]
}
1 change: 1 addition & 0 deletions packages/deno/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export * from "./should-render-graphiql.ts";
export * from "./types.ts";
export * from "./errors.ts";
export * from "./send-result/node-http.ts";
export * from "./send-result/w3c.ts";
2 changes: 1 addition & 1 deletion packages/deno/render-graphiql.ts

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions packages/deno/send-result/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { ExecutionResult } from "https://cdn.skypack.dev/graphql@16.0.0-experimental-stream-defer.5?dts";

export type TransformResultFn = (result: ExecutionResult) => any;
export const DEFAULT_TRANSFORM_RESULT_FN: TransformResultFn = (result: ExecutionResult) => result;
Loading

0 comments on commit 1ada48e

Please sign in to comment.