This repository has been archived by the owner on Dec 20, 2024. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 49
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
W3 Compatible Response handlers (#89)
- Loading branch information
Showing
13 changed files
with
1,320 additions
and
838 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"graphql-helix": minor | ||
--- | ||
|
||
feat: W3C Response handlers |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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."); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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} | ||
----- | ||
" | ||
`); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -22,5 +22,5 @@ | |
"node_modules", | ||
"packages/graphql-helix/test" | ||
], | ||
"include": ["lib"] | ||
"include": ["lib", "declarations.d.ts"] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
import { ExecutionResult } from "https://cdn.skypack.dev/[email protected]?dts"; | ||
|
||
export type TransformResultFn = (result: ExecutionResult) => any; | ||
export const DEFAULT_TRANSFORM_RESULT_FN: TransformResultFn = (result: ExecutionResult) => result; |
Oops, something went wrong.