fix(ci): enable deno and fix the pipeline

pull/29/head
Minghe 2022-07-24 17:24:21 +08:00 committed by GitHub
commit 9da7fcd238
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 1665 additions and 1971 deletions

View File

@ -13,7 +13,7 @@ jobs:
- uses: actions/setup-node@v2
with:
node-version: 16.x
- run: yarn install --frozen-lockfile
- run: yarn
- run: yarn build
- run: yarn test

View File

@ -0,0 +1,47 @@
# GraphQL Server Middleware
## Requirements
This middleware depends on [GraphQL.js](https://www.npmjs.com/package/graphql).
```plain
npm i graphql
```
or
```plain
yarn add graphql
```
## Usage
index.js:
```js
import { Hono } from 'hono'
import { graphqlServer } from 'hono/graphql-server'
import { buildSchema } from 'graphql'
export const app = new Hono()
const schema = buildSchema(`
type Query {
hello: String
}
`)
const rootValue = {
hello: () => 'Hello Hono!',
}
app.use(
'/graphql',
graphqlServer({
schema,
rootValue,
})
)
app.fire()
```

View File

@ -0,0 +1,573 @@
import {
buildSchema,
GraphQLSchema,
GraphQLString,
GraphQLObjectType,
GraphQLNonNull,
} from 'https://cdn.skypack.dev/graphql@16.5.0?dts'
import { Hono } from 'https://raw.githubusercontent.com/honojs/hono/v2.0.2/deno_dist/mod.ts'
import { errorMessages, graphqlServer } from './index.ts'
// Do not show `console.error` messages
jest.spyOn(console, 'error').mockImplementation()
describe('errorMessages', () => {
const messages = errorMessages(['message a', 'message b'])
expect(messages).toEqual({
errors: [
{
message: 'message a',
},
{
message: 'message b',
},
],
})
})
describe('GraphQL Middleware - Simple way', () => {
// Construct a schema, using GraphQL schema language
const schema = buildSchema(`
type Query {
hello: String
}
`)
// The root provides a resolver function for each API endpoint
const rootValue = {
hello: () => 'Hello world!',
}
const app = new Hono()
app.use(
'/graphql',
graphqlServer({
schema,
rootValue,
})
)
app.all('*', (c) => {
c.header('foo', 'bar')
return c.text('fallback')
})
it('Should return GraphQL response', async () => {
const query = 'query { hello }'
const body = {
query: query,
}
const res = await app.request('http://localhost/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
expect(res).not.toBeNull()
expect(res.status).toBe(200)
expect(await res.text()).toBe('{"data":{"hello":"Hello world!"}}')
expect(res.headers.get('foo')).toBeNull() // GraphQL Server middleware should be Handler
})
})
const QueryRootType = new GraphQLObjectType({
name: 'QueryRoot',
fields: {
test: {
type: GraphQLString,
args: {
who: { type: GraphQLString },
},
resolve: (_root, args: { who?: string }) => 'Hello ' + (args.who ?? 'World'),
},
thrower: {
type: GraphQLString,
resolve() {
throw new Error('Throws!')
},
},
},
})
const TestSchema = new GraphQLSchema({
query: QueryRootType,
mutation: new GraphQLObjectType({
name: 'MutationRoot',
fields: {
writeTest: {
type: QueryRootType,
resolve: () => ({}),
},
},
}),
})
const urlString = (query?: Record<string, string>): string => {
const base = 'http://localhost/graphql'
if (!query) return base
const queryString = new URLSearchParams(query).toString()
return `${base}?${queryString}`
}
describe('GraphQL Middleware - GET functionality', () => {
const app = new Hono()
app.use(
'/graphql',
graphqlServer({
schema: TestSchema,
})
)
it('Allows GET with variable values', async () => {
const query = {
query: 'query helloWho($who: String){ test(who: $who) }',
variables: JSON.stringify({ who: 'Dolly' }),
}
const res = await app.request(urlString(query), {
method: 'GET',
})
expect(res.status).toBe(200)
expect(await res.text()).toBe('{"data":{"test":"Hello Dolly"}}')
})
it('Allows GET with operation name', async () => {
const query = {
query: `
query helloYou { test(who: "You"), ...shared }
query helloWorld { test(who: "World"), ...shared }
query helloDolly { test(who: "Dolly"), ...shared }
fragment shared on QueryRoot {
shared: test(who: "Everyone")
}
`,
operationName: 'helloWorld',
}
const res = await app.request(urlString(query), {
method: 'GET',
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
data: {
test: 'Hello World',
shared: 'Hello Everyone',
},
})
})
it('Reports validation errors', async () => {
const query = { query: '{ test, unknownOne, unknownTwo }' }
const res = await app.request(urlString(query), {
method: 'GET',
})
expect(res.status).toBe(400)
})
it('Errors when missing operation name', async () => {
const query = {
query: `
query TestQuery { test }
mutation TestMutation { writeTest { test } }
`,
}
const res = await app.request(urlString(query), {
method: 'GET',
})
expect(res.status).toBe(500)
})
it('Errors when sending a mutation via GET', async () => {
const query = {
query: 'mutation TestMutation { writeTest { test } }',
}
const res = await app.request(urlString(query), {
method: 'GET',
})
expect(res.status).toBe(405)
})
it('Errors when selecting a mutation within a GET', async () => {
const query = {
operationName: 'TestMutation',
query: `
query TestQuery { test }
mutation TestMutation { writeTest { test } }
`,
}
const res = await app.request(urlString(query), {
method: 'GET',
})
expect(res.status).toBe(405)
})
it('Allows a mutation to exist within a GET', async () => {
const query = {
operationName: 'TestQuery',
query: `
mutation TestMutation { writeTest { test } }
query TestQuery { test }
`,
}
const res = await app.request(urlString(query), {
method: 'GET',
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
data: {
test: 'Hello World',
},
})
})
})
describe('GraphQL Middleware - POST functionality', () => {
const app = new Hono()
app.use(
'/graphql',
graphqlServer({
schema: TestSchema,
})
)
it('Allows POST with JSON encoding', async () => {
const query = { query: '{test}' }
const res = await app.request(urlString(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(query),
})
expect(res.status).toBe(200)
expect(await res.text()).toBe('{"data":{"test":"Hello World"}}')
})
it('Allows sending a mutation via POST', async () => {
const query = { query: 'mutation TestMutation { writeTest { test } }' }
const res = await app.request(urlString(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(query),
})
expect(res.status).toBe(200)
expect(await res.text()).toBe('{"data":{"writeTest":{"test":"Hello World"}}}')
})
it('Allows POST with url encoding', async () => {
const query = {
query: '{test}',
}
const res = await app.request(urlString(query), {
method: 'POST',
})
expect(res.status).toBe(200)
expect(await res.text()).toBe('{"data":{"test":"Hello World"}}')
})
it('Supports POST JSON query with string variables', async () => {
const query = {
query: 'query helloWho($who: String){ test(who: $who) }',
variables: JSON.stringify({ who: 'Dolly' }),
}
const res = await app.request(urlString(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(query),
})
expect(res.status).toBe(200)
expect(await res.text()).toBe('{"data":{"test":"Hello Dolly"}}')
})
it('Supports POST url encoded query with string variables', async () => {
const searchParams = new URLSearchParams({
query: 'query helloWho($who: String){ test(who: $who) }',
variables: JSON.stringify({ who: 'Dolly' }),
})
const res = await app.request(urlString(), {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: searchParams.toString(),
})
expect(res.status).toBe(200)
expect(await res.text()).toBe('{"data":{"test":"Hello Dolly"}}')
})
it('Supports POST JSON query with GET variable values', async () => {
const variables = {
variables: JSON.stringify({ who: 'Dolly' }),
}
const query = { query: 'query helloWho($who: String){ test(who: $who) }' }
const res = await app.request(urlString(variables), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(query),
})
expect(res.status).toBe(200)
expect(await res.text()).toBe('{"data":{"test":"Hello Dolly"}}')
})
it('Supports POST url encoded query with GET variable values', async () => {
const searchParams = new URLSearchParams({
query: 'query helloWho($who: String){ test(who: $who) }',
})
const variables = {
variables: JSON.stringify({ who: 'Dolly' }),
}
const res = await app.request(urlString(variables), {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: searchParams.toString(),
})
expect(res.status).toBe(200)
expect(await res.text()).toBe('{"data":{"test":"Hello Dolly"}}')
})
it('Supports POST raw text query with GET variable values', async () => {
const variables = {
variables: JSON.stringify({ who: 'Dolly' }),
}
const res = await app.request(urlString(variables), {
method: 'POST',
headers: {
'Content-Type': 'application/graphql',
},
body: 'query helloWho($who: String){ test(who: $who) }',
})
expect(res.status).toBe(200)
expect(await res.text()).toBe('{"data":{"test":"Hello Dolly"}}')
})
it('Allows POST with operation name', async () => {
const query = {
query: `
query helloYou { test(who: "You"), ...shared }
query helloWorld { test(who: "World"), ...shared }
query helloDolly { test(who: "Dolly"), ...shared }
fragment shared on QueryRoot {
shared: test(who: "Everyone")
}
`,
operationName: 'helloWorld',
}
const res = await app.request(urlString(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(query),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
data: {
test: 'Hello World',
shared: 'Hello Everyone',
},
})
})
it('Allows POST with GET operation name', async () => {
const res = await app.request(
urlString({
operationName: 'helloWorld',
}),
{
method: 'POST',
headers: {
'Content-Type': 'application/graphql',
},
body: `
query helloYou { test(who: "You"), ...shared }
query helloWorld { test(who: "World"), ...shared }
query helloDolly { test(who: "Dolly"), ...shared }
fragment shared on QueryRoot {
shared: test(who: "Everyone")
}
`,
}
)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
data: {
test: 'Hello World',
shared: 'Hello Everyone',
},
})
})
})
describe('Pretty printing', () => {
it('Supports pretty printing', async () => {
const app = new Hono()
app.use(
'/graphql',
graphqlServer({
schema: TestSchema,
pretty: true,
})
)
const res = await app.request(urlString({ query: '{test}' }))
expect(await res.text()).toEqual(
[
// Pretty printed JSON
'{',
' "data": {',
' "test": "Hello World"',
' }',
'}',
].join('\n')
)
})
})
describe('Error handling functionality', () => {
const app = new Hono()
app.use(
'/graphql',
graphqlServer({
schema: TestSchema,
})
)
it('Handles query errors from non-null top field errors', async () => {
const schema = new GraphQLSchema({
query: new GraphQLObjectType({
name: 'Query',
fields: {
test: {
type: new GraphQLNonNull(GraphQLString),
resolve() {
throw new Error('Throws!')
},
},
},
}),
})
const app = new Hono()
app.use('/graphql', graphqlServer({ schema }))
const res = await app.request(
urlString({
query: '{ test }',
})
)
expect(res.status).toBe(500)
})
it('Handles syntax errors caught by GraphQL', async () => {
const res = await app.request(
urlString({
query: 'syntax_error',
}),
{
method: 'GET',
}
)
expect(res.status).toBe(400)
})
it('Handles errors caused by a lack of query', async () => {
const res = await app.request(urlString(), {
method: 'GET',
})
expect(res.status).toBe(400)
})
it('Handles invalid JSON bodies', async () => {
const res = await app.request(urlString(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify([]),
})
expect(res.status).toBe(400)
})
it('Handles incomplete JSON bodies', async () => {
const res = await app.request(urlString(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: '{"query":',
})
expect(res.status).toBe(400)
})
it('Handles plain POST text', async () => {
const res = await app.request(
urlString({
variables: JSON.stringify({ who: 'Dolly' }),
}),
{
method: 'POST',
headers: {
'Content-Type': 'text/plain',
},
body: 'query helloWho($who: String){ test(who: $who) }',
}
)
expect(res.status).toBe(400)
})
it('Handles poorly formed variables', async () => {
const res = await app.request(
urlString({
variables: 'who:You',
query: 'query helloWho($who: String){ test(who: $who) }',
}),
{
method: 'GET',
}
)
expect(res.status).toBe(400)
})
it('Handles invalid variables', async () => {
const res = await app.request(urlString(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: 'query helloWho($who: String){ test(who: $who) }',
variables: { who: ['John', 'Jane'] },
}),
})
expect(res.status).toBe(500)
})
it('Handles unsupported HTTP methods', async () => {
const res = await app.request(urlString({ query: '{test}' }), {
method: 'PUT',
})
expect(res.status).toBe(405)
expect(res.headers.get('allow')).toBe('GET, POST')
expect(await res.json()).toEqual({
errors: [{ message: 'GraphQL only supports GET and POST requests.' }],
})
})
})

260
deno_dist/index.ts 100644
View File

@ -0,0 +1,260 @@
// Based on the code in the `express-graphql` package.
// https://github.com/graphql/express-graphql/blob/main/src/index.ts
import {
Source,
parse,
execute,
validateSchema,
validate,
specifiedRules,
getOperationAST,
GraphQLError,
} from "https://cdn.skypack.dev/graphql@16.5.0?dts";
import type {
GraphQLSchema,
DocumentNode,
ValidationRule,
FormattedExecutionResult,
GraphQLFormattedError,
} from "https://cdn.skypack.dev/graphql@16.5.0?dts";
import type { Context } from "https://raw.githubusercontent.com/honojs/hono/v2.0.2/deno_dist/mod.ts";
import type { Next } from "https://raw.githubusercontent.com/honojs/hono/v2.0.2/deno_dist/mod.ts";
import { parseBody } from "./parse-body.ts";
type Options = {
schema: GraphQLSchema;
rootValue?: unknown;
pretty?: boolean;
validationRules?: ReadonlyArray<ValidationRule>;
// graphiql?: boolean
};
export const graphqlServer = (options: Options) => {
const schema = options.schema;
const rootValue = options.rootValue;
const pretty = options.pretty ?? false;
const validationRules = options.validationRules ?? [];
// const showGraphiQL = options.graphiql ?? false
return async (c: Context, next: Next) => {
// GraphQL HTTP only supports GET and POST methods.
if (c.req.method !== "GET" && c.req.method !== "POST") {
return c.json(
errorMessages(["GraphQL only supports GET and POST requests."]),
405,
{
Allow: "GET, POST",
}
);
}
let params: GraphQLParams;
try {
params = await getGraphQLParams(c.req);
} catch (e) {
if (e instanceof Error) {
console.error(`${e.stack || e.message}`);
return c.json(errorMessages([e.message], [e]), 400);
}
throw e;
}
const { query, variables, operationName } = params;
if (query == null) {
return c.json(errorMessages(["Must provide query string."]), 400);
}
const schemaValidationErrors = validateSchema(schema);
if (schemaValidationErrors.length > 0) {
// Return 500: Internal Server Error if invalid schema.
return c.json(
errorMessages(
["GraphQL schema validation error."],
schemaValidationErrors
),
500
);
}
let documentAST: DocumentNode;
try {
documentAST = parse(new Source(query, "GraphQL request"));
} catch (syntaxError: unknown) {
// Return 400: Bad Request if any syntax errors errors exist.
if (syntaxError instanceof Error) {
console.error(`${syntaxError.stack || syntaxError.message}`);
const e = new GraphQLError(syntaxError.message, {
originalError: syntaxError,
});
return c.json(errorMessages(["GraphQL syntax error."], [e]), 400);
}
throw syntaxError;
}
// Validate AST, reporting any errors.
const validationErrors = validate(schema, documentAST, [
...specifiedRules,
...validationRules,
]);
if (validationErrors.length > 0) {
// Return 400: Bad Request if any validation errors exist.
return c.json(
errorMessages(["GraphQL validation error."], validationErrors),
400
);
}
if (c.req.method === "GET") {
// Determine if this GET request will perform a non-query.
const operationAST = getOperationAST(documentAST, operationName);
if (operationAST && operationAST.operation !== "query") {
/*
Now , does not support GraphiQL
if (showGraphiQL) {
//return respondWithGraphiQL(response, graphiqlOptions, params)
}
*/
// Otherwise, report a 405: Method Not Allowed error.
return c.json(
errorMessages([
`Can only perform a ${operationAST.operation} operation from a POST request.`,
]),
405,
{ Allow: "POST" }
);
}
}
let result: FormattedExecutionResult;
try {
result = await execute({
schema,
document: documentAST,
rootValue,
variableValues: variables,
operationName: operationName,
});
} catch (contextError: unknown) {
if (contextError instanceof Error) {
console.error(`${contextError.stack || contextError.message}`);
const e = new GraphQLError(contextError.message, {
originalError: contextError,
nodes: documentAST,
});
// Return 400: Bad Request if any execution context errors exist.
return c.json(
errorMessages(["GraphQL execution context error."], [e]),
400
);
}
throw contextError;
}
if (!result.data) {
if (result.errors) {
return c.json(
errorMessages([result.errors.toString()], result.errors),
500
);
}
}
/*
Now, does not support GraphiQL
if (showGraphiQL) {
}
*/
if (pretty) {
const payload = JSON.stringify(result, null, pretty ? 2 : 0);
return c.text(payload, 200, {
"Content-Type": "application/json",
});
} else {
return c.json(result);
}
await next(); // XXX
};
};
export interface GraphQLParams {
query: string | null;
variables: { readonly [name: string]: unknown } | null;
operationName: string | null;
raw: boolean;
}
export const getGraphQLParams = async (
request: Request
): Promise<GraphQLParams> => {
const urlData = new URLSearchParams(request.url.split("?")[1]);
const bodyData = await parseBody(request);
// GraphQL Query string.
let query = urlData.get("query") ?? (bodyData.query as string | null);
if (typeof query !== "string") {
query = null;
}
// Parse the variables if needed.
let variables = (urlData.get("variables") ?? bodyData.variables) as {
readonly [name: string]: unknown;
} | null;
if (typeof variables === "string") {
try {
variables = JSON.parse(variables);
} catch {
throw Error("Variables are invalid JSON.");
}
} else if (typeof variables !== "object") {
variables = null;
}
// Name of GraphQL operation to execute.
let operationName =
urlData.get("operationName") ?? (bodyData.operationName as string | null);
if (typeof operationName !== "string") {
operationName = null;
}
const raw = urlData.get("raw") != null || bodyData.raw !== undefined;
const params: GraphQLParams = {
query: query,
variables: variables,
operationName: operationName,
raw: raw,
};
return params;
};
export const errorMessages = (
messages: string[],
graphqlErrors?: readonly GraphQLError[] | readonly GraphQLFormattedError[]
) => {
if (graphqlErrors) {
return {
errors: graphqlErrors,
};
}
return {
errors: messages.map((message) => {
return {
message: message,
};
}),
};
};
// export const graphiQLResponse = () => {}

1
deno_dist/mod.ts 100644
View File

@ -0,0 +1 @@
export * from "./index.ts";

View File

@ -0,0 +1,61 @@
import { parseBody } from './parse-body.ts'
describe('parseBody', () => {
it('Should return a blank JSON object', async () => {
const req = new Request('http://localhost/graphql')
const res = await parseBody(req)
expect(res).toEqual({})
})
it('With Content-Type: application/graphql', async () => {
const req = new Request('http://localhost/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/graphql',
},
body: 'query { hello }',
})
const res = await parseBody(req)
expect(res).toEqual({ query: 'query { hello }' })
})
it('With Content-Type: application/json', async () => {
const variables = JSON.stringify({ who: 'Dolly' })
const req = new Request('http://localhost/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: 'query { hello }',
variables: variables,
}),
})
const res = await parseBody(req)
expect(res).toEqual({
query: 'query { hello }',
variables: variables,
})
})
it('With Content-Type: application/x-www-form-urlencoded', async () => {
const variables = JSON.stringify({ who: 'Dolly' })
const searchParams = new URLSearchParams()
searchParams.append('query', 'query { hello }')
searchParams.append('variables', variables)
const req = new Request('http://localhost/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: searchParams.toString(),
})
const res = await parseBody(req)
expect(res).toEqual({
query: 'query { hello }',
variables: variables,
})
})
})

View File

@ -0,0 +1,29 @@
export async function parseBody(req: Request): Promise<Record<string, unknown>> {
const contentType = req.headers.get('content-type')
switch (contentType) {
case 'application/graphql':
return { query: await req.text() }
case 'application/json':
try {
return await req.json()
} catch (e) {
if (e instanceof Error) {
console.error(`${e.stack || e.message}`)
}
throw Error(`POST body sent invalid JSON: ${e}`)
}
case 'application/x-www-form-urlencoded':
return parseFormURL(req)
}
return {}
}
const parseFormURL = async (req: Request) => {
const text = await req.text()
const searchParams = new URLSearchParams(text)
const res: { [params: string]: string } = {}
searchParams.forEach((v, k) => (res[k] = v))
return res
}

View File

@ -0,0 +1,49 @@
import { buildSchema } from "https://cdn.skypack.dev/graphql@16.5.0?dts";
import { Hono } from "https://deno.land/x/hono@v2.0.3/mod.ts";
import { errorMessages, graphqlServer } from "../deno_dist/index.ts";
import { assertEquals } from "https://deno.land/std@0.149.0/testing/asserts.ts";
Deno.test("graphql-server", async (t) => {
// Construct a schema, using GraphQL schema language
const schema = buildSchema(`
type Query {
hello: String
}
`);
// The root provides a resolver function for each API endpoint
const rootValue = {
hello: () => "Hello world!",
};
const app = new Hono();
app.use(
"/graphql",
graphqlServer({
schema,
rootValue,
})
);
app.all("*", (c) => {
c.header("foo", "bar");
return c.text("fallback");
});
const query = "query { hello }";
const body = {
query: query,
};
const res = await app.request("http://localhost/graphql", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
assertEquals(res.status, 200)
assertEquals(await res.text(), '{"data":{"hello":"Hello world!"}}')
});

2614
yarn.lock

File diff suppressed because it is too large Load Diff