Add 'packages/graphql-server/' from commit 'c7e949b0f93954d44c3c7f15564dd9e3ce8ad709'
git-subtree-dir: packages/graphql-server git-subtree-mainline:pull/29/headfc30bf65cb
git-subtree-split:c7e949b0f9
commit
3f6f302864
|
@ -0,0 +1 @@
|
||||||
|
dist
|
|
@ -0,0 +1,60 @@
|
||||||
|
const { defineConfig } = require('eslint-define-config')
|
||||||
|
|
||||||
|
module.exports = defineConfig({
|
||||||
|
root: true,
|
||||||
|
extends: [
|
||||||
|
'eslint:recommended',
|
||||||
|
'plugin:node/recommended',
|
||||||
|
'plugin:@typescript-eslint/recommended',
|
||||||
|
'prettier',
|
||||||
|
],
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
parserOptions: {
|
||||||
|
sourceType: 'module',
|
||||||
|
ecmaVersion: 2021,
|
||||||
|
},
|
||||||
|
plugins: ['@typescript-eslint', 'import'],
|
||||||
|
globals: {
|
||||||
|
fetch: false,
|
||||||
|
Response: false,
|
||||||
|
Request: false,
|
||||||
|
addEventListener: false,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
quotes: ['error', 'single'],
|
||||||
|
semi: ['error', 'never'],
|
||||||
|
'no-debugger': ['error'],
|
||||||
|
'no-empty': ['warn', { allowEmptyCatch: true }],
|
||||||
|
'no-process-exit': 'off',
|
||||||
|
'no-useless-escape': 'off',
|
||||||
|
'prefer-const': [
|
||||||
|
'warn',
|
||||||
|
{
|
||||||
|
destructuring: 'all',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'@typescript-eslint/ban-types': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
types: {
|
||||||
|
Function: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'sort-imports': 0,
|
||||||
|
'import/order': [2, { alphabetize: { order: 'asc' } }],
|
||||||
|
|
||||||
|
'node/no-missing-import': 'off',
|
||||||
|
'node/no-missing-require': 'off',
|
||||||
|
'node/no-deprecated-api': 'off',
|
||||||
|
'node/no-unpublished-import': 'off',
|
||||||
|
'node/no-unpublished-require': 'off',
|
||||||
|
'node/no-unsupported-features/es-syntax': 'off',
|
||||||
|
|
||||||
|
'@typescript-eslint/no-empty-function': ['error', { allow: ['arrowFunctions'] }],
|
||||||
|
'@typescript-eslint/no-empty-interface': 'off',
|
||||||
|
'@typescript-eslint/no-inferrable-types': 'off',
|
||||||
|
'@typescript-eslint/no-var-requires': 'off',
|
||||||
|
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
|
||||||
|
},
|
||||||
|
})
|
|
@ -0,0 +1,37 @@
|
||||||
|
name: ci
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: ['*']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- uses: actions/setup-node@v2
|
||||||
|
with:
|
||||||
|
node-version: 16.x
|
||||||
|
- run: yarn
|
||||||
|
- run: yarn lint
|
||||||
|
- run: yarn build
|
||||||
|
- run: yarn test
|
||||||
|
|
||||||
|
deno:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- uses: denoland/setup-deno@v1
|
||||||
|
with:
|
||||||
|
deno-version: v1.x
|
||||||
|
- run: deno test deno_test
|
||||||
|
|
||||||
|
bun:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- run: curl -fsSL https://bun.sh/install | bash
|
||||||
|
- run: echo "${HOME}/.bun/bin" >> $GITHUB_PATH
|
||||||
|
- run: bun install
|
||||||
|
- run: bun wiptest bun_test/index.test.ts
|
|
@ -0,0 +1,21 @@
|
||||||
|
name: publish
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [production]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 16.x
|
||||||
|
- run: yarn
|
||||||
|
- run: yarn build
|
||||||
|
- run: yarn test
|
||||||
|
- name: publish
|
||||||
|
uses: mikeal/merge-release@master
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
NPM_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
@ -0,0 +1,3 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
yarn-error.log
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"printWidth": 100,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"tabWidth": 2,
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"jsxSingleQuote": true,
|
||||||
|
"endOfLine": "lf"
|
||||||
|
}
|
|
@ -0,0 +1,55 @@
|
||||||
|
# GraphQL Server Middleware
|
||||||
|
|
||||||
|
## Information
|
||||||
|
|
||||||
|
GraphQL Server Middleware `@honojs/graphql-server` is renamed to `@hono/graphql-server`.
|
||||||
|
`@honojs/graphql-server` is not maintained, please use `@hono/graphql-server`.
|
||||||
|
Also, for Deno, you can use import with `npm:` prefix like `npm:@hono/graphql-server`.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
This middleware depends on [GraphQL.js](https://www.npmjs.com/package/graphql).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm i @hono/graphql-server
|
||||||
|
```
|
||||||
|
|
||||||
|
or
|
||||||
|
|
||||||
|
```plain
|
||||||
|
yarn add @hono/graphql-server
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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 rootResolver = (ctx) => {
|
||||||
|
return {
|
||||||
|
hello: () => 'Hello Hono!',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
'/graphql',
|
||||||
|
graphqlServer({
|
||||||
|
schema,
|
||||||
|
rootResolver,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
app.fire()
|
||||||
|
```
|
|
@ -0,0 +1,569 @@
|
||||||
|
import { describe, expect, it } from 'bun:test'
|
||||||
|
import {
|
||||||
|
buildSchema,
|
||||||
|
GraphQLSchema,
|
||||||
|
GraphQLString,
|
||||||
|
GraphQLObjectType,
|
||||||
|
GraphQLNonNull,
|
||||||
|
} from 'graphql'
|
||||||
|
import { Hono } from 'hono'
|
||||||
|
import { graphqlServer } from '../src/index'
|
||||||
|
|
||||||
|
// Test just only minimal patterns.
|
||||||
|
// Because others are tested well in Cloudflare Workers environment already.
|
||||||
|
|
||||||
|
describe('graphql-server', () => {
|
||||||
|
// 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,
|
||||||
|
rootResolver: () => 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),
|
||||||
|
})
|
||||||
|
// TODO enable this
|
||||||
|
// expect(res).not.toBeNull()
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(await res.text()).toBe('{"data":{"hello":"Hello world!"}}')
|
||||||
|
// TODO enable this
|
||||||
|
// 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)
|
||||||
|
// TODO enable this
|
||||||
|
// 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)
|
||||||
|
// TODO enable this
|
||||||
|
// 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)
|
||||||
|
// TODO enable this
|
||||||
|
// 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)
|
||||||
|
// TODO enable this
|
||||||
|
// 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}' }))
|
||||||
|
|
||||||
|
// TODO enable this
|
||||||
|
// 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')
|
||||||
|
// TODO enable this
|
||||||
|
// expect(await res.json()).toEqual({
|
||||||
|
// errors: [{ message: 'GraphQL only supports GET and POST requests.' }],
|
||||||
|
// })
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1,55 @@
|
||||||
|
# GraphQL Server Middleware
|
||||||
|
|
||||||
|
## Information
|
||||||
|
|
||||||
|
GraphQL Server Middleware `@honojs/graphql-server` is renamed to `@hono/graphql-server`.
|
||||||
|
`@honojs/graphql-server` is not maintained, please use `@hono/graphql-server`.
|
||||||
|
Also, for Deno, you can use import with `npm:` prefix like `npm:@hono/graphql-server`.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
This middleware depends on [GraphQL.js](https://www.npmjs.com/package/graphql).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm i @hono/graphql-server
|
||||||
|
```
|
||||||
|
|
||||||
|
or
|
||||||
|
|
||||||
|
```plain
|
||||||
|
yarn add @hono/graphql-server
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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 rootResolver = (ctx) => {
|
||||||
|
return {
|
||||||
|
hello: () => 'Hello Hono!',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
'/graphql',
|
||||||
|
graphqlServer({
|
||||||
|
schema,
|
||||||
|
rootResolver,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
app.fire()
|
||||||
|
```
|
|
@ -0,0 +1,234 @@
|
||||||
|
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://deno.land/x/hono/mod.ts'
|
||||||
|
import { parseBody } from './parse-body.ts'
|
||||||
|
|
||||||
|
export type RootResolver = (ctx?: Context) => Promise<unknown> | unknown
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
schema: GraphQLSchema
|
||||||
|
rootResolver?: RootResolver,
|
||||||
|
pretty?: boolean
|
||||||
|
validationRules?: ReadonlyArray<ValidationRule>
|
||||||
|
// graphiql?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const graphqlServer = (options: Options) => {
|
||||||
|
const schema = options.schema
|
||||||
|
const pretty = options.pretty ?? false
|
||||||
|
const validationRules = options.validationRules ?? []
|
||||||
|
// const showGraphiQL = options.graphiql ?? false
|
||||||
|
|
||||||
|
return async (c: Context) => {
|
||||||
|
// 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
|
||||||
|
const { rootResolver } = options
|
||||||
|
|
||||||
|
try {
|
||||||
|
result = await execute({
|
||||||
|
schema,
|
||||||
|
document: documentAST,
|
||||||
|
rootValue: rootResolver ? await rootResolver(c) : null,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,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,48 @@
|
||||||
|
import { buildSchema } from 'https://cdn.skypack.dev/graphql@16.5.0?dts'
|
||||||
|
import { Hono } from 'https://deno.land/x/hono@v2.6.1/mod.ts'
|
||||||
|
import { 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,
|
||||||
|
rootResolver: () => 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!"}}')
|
||||||
|
})
|
|
@ -0,0 +1,7 @@
|
||||||
|
module.exports = {
|
||||||
|
testMatch: ['**/test/**/*.+(ts|tsx|js)', '**/src/**/(*.)+(spec|test).+(ts|tsx|js)'],
|
||||||
|
transform: {
|
||||||
|
'^.+\\.(ts|tsx)$': 'ts-jest',
|
||||||
|
},
|
||||||
|
testEnvironment: 'miniflare',
|
||||||
|
}
|
|
@ -0,0 +1,64 @@
|
||||||
|
{
|
||||||
|
"name": "@honojs/graphql-server",
|
||||||
|
"version": "0.1.2",
|
||||||
|
"repository": "git@github.com:honojs/grahql-server.git",
|
||||||
|
"author": "Minghe Huang <h.minghe@gmail.com>",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"private": false,
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public",
|
||||||
|
"registry": "https://registry.npmjs.org/"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "jest",
|
||||||
|
"test:deno": "deno test deno_test",
|
||||||
|
"test:bun": "bun wiptest bun_test/index.test.ts",
|
||||||
|
"test:all": "yarn test && yarn test:deno && yarn test:bun",
|
||||||
|
"denoify": "rimraf deno_dist && denoify && rimraf 'deno_dist/**/*.test.ts'",
|
||||||
|
"build": "rimraf dist && tsc --project tsconfig.build.json",
|
||||||
|
"lint": "eslint --ext js,ts src .eslintrc.js",
|
||||||
|
"lint:fix": "eslint --ext js,ts src .eslintrc.js --fix",
|
||||||
|
"prerelease": "yarn build && yarn denoify && yarn test:all",
|
||||||
|
"release": "np"
|
||||||
|
},
|
||||||
|
"denoify": {
|
||||||
|
"replacer": "dist/replacer.js"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"hono": "^2.6.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"graphql": "^16.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@cloudflare/workers-types": "^3.14.0",
|
||||||
|
"@types/jest": "^28.1.4",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^5.21.0",
|
||||||
|
"@typescript-eslint/parser": "^5.21.0",
|
||||||
|
"denoify": "^1.4.5",
|
||||||
|
"eslint": "^8.14.0",
|
||||||
|
"eslint-config-prettier": "^8.5.0",
|
||||||
|
"eslint-define-config": "^1.4.0",
|
||||||
|
"eslint-import-resolver-typescript": "^2.7.1",
|
||||||
|
"eslint-plugin-eslint-comments": "^3.2.0",
|
||||||
|
"eslint-plugin-flowtype": "^8.0.3",
|
||||||
|
"eslint-plugin-import": "^2.26.0",
|
||||||
|
"eslint-plugin-node": "^11.1.0",
|
||||||
|
"hono": "^2.6.1",
|
||||||
|
"jest": "^28.1.2",
|
||||||
|
"jest-environment-miniflare": "^2.6.0",
|
||||||
|
"np": "^7.6.2",
|
||||||
|
"prettier": "^2.7.1",
|
||||||
|
"rimraf": "^3.0.2",
|
||||||
|
"ts-jest": "^28.0.5",
|
||||||
|
"typescript": "^4.7.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=11.0.0"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,626 @@
|
||||||
|
import {
|
||||||
|
buildSchema,
|
||||||
|
GraphQLSchema,
|
||||||
|
GraphQLString,
|
||||||
|
GraphQLObjectType,
|
||||||
|
GraphQLNonNull,
|
||||||
|
} from 'graphql'
|
||||||
|
import { Hono } from 'hono'
|
||||||
|
import type { Context, Next } from 'hono'
|
||||||
|
import { errorMessages, graphqlServer } from '.'
|
||||||
|
import type { RootResolver } from './index'
|
||||||
|
|
||||||
|
// 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', () => {
|
||||||
|
it('Should return GraphQL response', async () => {
|
||||||
|
const schema = buildSchema(`
|
||||||
|
type Query {
|
||||||
|
hello: String
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
const rootValue = {
|
||||||
|
hello: () => 'Hello world!',
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = new Hono()
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
'/graphql',
|
||||||
|
graphqlServer({
|
||||||
|
schema,
|
||||||
|
rootResolver: () => 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),
|
||||||
|
})
|
||||||
|
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
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should access the ctx', async () => {
|
||||||
|
const schema = buildSchema(`
|
||||||
|
type Query {
|
||||||
|
hi: String
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
const rootResolver: RootResolver = (ctx?: Context) => {
|
||||||
|
const name = ctx?.get('name')
|
||||||
|
return {
|
||||||
|
hi: `hi ${name}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = new Hono()
|
||||||
|
|
||||||
|
app.use('*', async (ctx: Context, next: Next) => {
|
||||||
|
ctx.set('name', 'Jason')
|
||||||
|
await next()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
'/graphql',
|
||||||
|
graphqlServer({
|
||||||
|
schema,
|
||||||
|
rootResolver,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
app.all('*', (c) => {
|
||||||
|
c.header('foo', 'bar')
|
||||||
|
return c.text('fallback')
|
||||||
|
})
|
||||||
|
|
||||||
|
const query = 'query { hi }'
|
||||||
|
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":{"hi":"hi Jason"}}')
|
||||||
|
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,234 @@
|
||||||
|
import {
|
||||||
|
Source,
|
||||||
|
parse,
|
||||||
|
execute,
|
||||||
|
validateSchema,
|
||||||
|
validate,
|
||||||
|
specifiedRules,
|
||||||
|
getOperationAST,
|
||||||
|
GraphQLError,
|
||||||
|
} from 'graphql'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
GraphQLSchema,
|
||||||
|
DocumentNode,
|
||||||
|
ValidationRule,
|
||||||
|
FormattedExecutionResult,
|
||||||
|
GraphQLFormattedError,
|
||||||
|
} from 'graphql'
|
||||||
|
|
||||||
|
import type { Context } from 'hono'
|
||||||
|
import { parseBody } from './parse-body'
|
||||||
|
|
||||||
|
export type RootResolver = (ctx?: Context) => Promise<unknown> | unknown
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
schema: GraphQLSchema
|
||||||
|
rootResolver?: RootResolver,
|
||||||
|
pretty?: boolean
|
||||||
|
validationRules?: ReadonlyArray<ValidationRule>
|
||||||
|
// graphiql?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const graphqlServer = (options: Options) => {
|
||||||
|
const schema = options.schema
|
||||||
|
const pretty = options.pretty ?? false
|
||||||
|
const validationRules = options.validationRules ?? []
|
||||||
|
// const showGraphiQL = options.graphiql ?? false
|
||||||
|
|
||||||
|
return async (c: Context) => {
|
||||||
|
// 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
|
||||||
|
const { rootResolver } = options
|
||||||
|
|
||||||
|
try {
|
||||||
|
result = await execute({
|
||||||
|
schema,
|
||||||
|
document: documentAST,
|
||||||
|
rootValue: rootResolver ? await rootResolver(c) : null,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,61 @@
|
||||||
|
import { parseBody } from './parse-body'
|
||||||
|
|
||||||
|
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,16 @@
|
||||||
|
// @denoify-ignore
|
||||||
|
import { makeThisModuleAnExecutableReplacer, ParsedImportExportStatement } from 'denoify'
|
||||||
|
|
||||||
|
makeThisModuleAnExecutableReplacer(async ({ parsedImportExportStatement }) => {
|
||||||
|
if (parsedImportExportStatement.parsedArgument.nodeModuleName === 'hono') {
|
||||||
|
return ParsedImportExportStatement.stringify({
|
||||||
|
...parsedImportExportStatement,
|
||||||
|
parsedArgument: {
|
||||||
|
type: 'URL',
|
||||||
|
url: 'https://deno.land/x/hono/mod.ts',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
})
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"src/deno/**/*.ts",
|
||||||
|
"src/**/*.test.ts"
|
||||||
|
]
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2020",
|
||||||
|
"module": "commonjs",
|
||||||
|
"declaration": true,
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strictPropertyInitialization": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"types": ["jest", "node", "@cloudflare/workers-types"],
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./dist"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue