fix(ci): enable deno and fix the pipeline
commit
9da7fcd238
|
@ -13,7 +13,7 @@ jobs:
|
||||||
- uses: actions/setup-node@v2
|
- uses: actions/setup-node@v2
|
||||||
with:
|
with:
|
||||||
node-version: 16.x
|
node-version: 16.x
|
||||||
- run: yarn install --frozen-lockfile
|
- run: yarn
|
||||||
- run: yarn build
|
- run: yarn build
|
||||||
- run: yarn test
|
- run: yarn test
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
|
```
|
|
@ -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.' }],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -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 = () => {}
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./index.ts";
|
|
@ -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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -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
|
||||||
|
}
|
|
@ -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!"}}')
|
||||||
|
});
|
Loading…
Reference in New Issue