diff --git a/CHANGELOG.md b/CHANGELOG.md index d38dc279d45..7fa97b868c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ### VNEXT +* add support for HTTP GET Method ([@DxCx](https://github.com/DxCx)) on [#180](https://github.com/apollostack/graphql-server/pull/180) ### v0.5.0 * Switch graphql typings for typescript to @types/graphql [#260](https://github.com/apollostack/graphql-server/pull/260) diff --git a/README.md b/README.md index b7561c617df..ed4afcef767 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ const PORT = 3000; var app = express(); +// bodyParser is needed just for POST. app.use('/graphql', bodyParser.json(), graphqlExpress({ schema: myGraphQLSchema })); app.listen(PORT); @@ -60,6 +61,7 @@ const PORT = 3000; var app = connect(); +// bodyParser is needed just for POST. app.use('/graphql', bodyParser.json()); app.use('/graphql', graphqlConnect({ schema: myGraphQLSchema })); @@ -108,24 +110,25 @@ server.start((err) => { ### Koa ```js import koa from 'koa'; // koa@2 -import koaBody from 'koa-bodyparser'; // koa-bodyparser@next import koaRouter from 'koa-router'; // koa-router@next +import koaBody from 'koa-bodyparser'; // koa-bodyparser@next import { graphqlKoa } from 'graphql-server-koa'; const app = new koa(); const router = new koaRouter(); const PORT = 3000; +// koaBody is needed just for POST. app.use(koaBody()); router.post('/graphql', graphqlKoa({ schema: myGraphQLSchema })); +router.get('/graphql', graphqlKoa({ schema: myGraphQLSchema })); + app.use(router.routes()); app.use(router.allowedMethods()); app.listen(PORT); ``` -## Options - GraphQL Server can be configured with an options object with the the following fields: * **schema**: the GraphQLSchema to be used diff --git a/packages/graphql-server-core/src/graphqlOptions.ts b/packages/graphql-server-core/src/graphqlOptions.ts index 9bc6f3d5a50..9407ce98f3d 100644 --- a/packages/graphql-server-core/src/graphqlOptions.ts +++ b/packages/graphql-server-core/src/graphqlOptions.ts @@ -15,7 +15,7 @@ import { LogFunction } from './runQuery'; * - (optional) debug: a boolean that will print additional debug logging if execution errors occur * */ -interface GraphQLServerOptions { +export interface GraphQLServerOptions { schema: GraphQLSchema; formatError?: Function; rootValue?: any; @@ -28,3 +28,7 @@ interface GraphQLServerOptions { } export default GraphQLServerOptions; + +export function isOptionsFunction(arg: GraphQLServerOptions | Function): arg is Function { + return typeof arg === 'function'; +} diff --git a/packages/graphql-server-core/src/index.ts b/packages/graphql-server-core/src/index.ts index 21389dca72d..38547bf26d3 100644 --- a/packages/graphql-server-core/src/index.ts +++ b/packages/graphql-server-core/src/index.ts @@ -1,2 +1,3 @@ export { runQuery, LogFunction, LogMessage, LogStep, LogAction } from './runQuery' -export { default as GraphQLOptions} from './graphqlOptions' +export { runHttpQuery, HttpQueryRequest, HttpQueryError } from './runHttpQuery'; +export { default as GraphQLOptions } from './graphqlOptions' diff --git a/packages/graphql-server-core/src/runHttpQuery.ts b/packages/graphql-server-core/src/runHttpQuery.ts new file mode 100644 index 00000000000..aaf6fe79520 --- /dev/null +++ b/packages/graphql-server-core/src/runHttpQuery.ts @@ -0,0 +1,154 @@ +import { parse, getOperationAST, DocumentNode, formatError, ExecutionResult } from 'graphql'; +import { runQuery } from './runQuery'; +import { default as GraphQLOptions, isOptionsFunction } from './graphqlOptions'; + +export interface HttpQueryRequest { + method: string; + query: string; + options: GraphQLOptions | Function; +} + +export class HttpQueryError extends Error { + public statusCode: number; + public isGraphQLError: boolean; + public headers: { [key: string]: string }; + + constructor (statusCode: number, message: string, isGraphQLError: boolean = false, headers?: { [key: string]: string }) { + super(message); + this.name = 'HttpQueryError'; + this.statusCode = statusCode; + this.isGraphQLError = isGraphQLError; + this.headers = headers; + } +} + +function isQueryOperation(query: DocumentNode, operationName: string) { + const operationAST = getOperationAST(query, operationName); + return operationAST.operation === 'query'; +} + +export async function runHttpQuery(handlerArguments: Array, request: HttpQueryRequest): Promise { + let isGetRequest: boolean = false; + let optionsObject: GraphQLOptions; + if (isOptionsFunction(request.options)) { + try { + optionsObject = await request.options(...handlerArguments); + } catch (e) { + throw new HttpQueryError(500, `Invalid options provided to ApolloServer: ${e.message}`); + } + } else { + optionsObject = request.options; + } + + const formatErrorFn = optionsObject.formatError || formatError; + let requestPayload; + + switch ( request.method ) { + case 'POST': + if ( !request.query ) { + throw new HttpQueryError(500, 'POST body missing. Did you forget use body-parser middleware?'); + } + + requestPayload = request.query; + break; + case 'GET': + if ( !request.query || (Object.keys(request.query).length === 0) ) { + throw new HttpQueryError(400, 'GET query missing.'); + } + + isGetRequest = true; + requestPayload = request.query; + break; + + default: + throw new HttpQueryError(405, 'Apollo Server supports only GET/POST requests.', false, { + 'Allow': 'GET, POST', + }); + } + + let isBatch = true; + // TODO: do something different here if the body is an array. + // Throw an error if body isn't either array or object. + if (!Array.isArray(requestPayload)) { + isBatch = false; + requestPayload = [requestPayload]; + } + + let responses: Array = []; + for (let requestParams of requestPayload) { + try { + let query = requestParams.query; + if ( isGetRequest ) { + if (typeof query === 'string') { + // preparse the query incase of GET so we can assert the operation. + query = parse(query); + } + + if ( ! isQueryOperation(query, requestParams.operationName) ) { + throw new HttpQueryError(405, `GET supports only query operation`, false, { + 'Allow': 'POST', + }); + } + } + + const operationName = requestParams.operationName; + let variables = requestParams.variables; + + if (typeof variables === 'string') { + try { + variables = JSON.parse(variables); + } catch (error) { + throw new HttpQueryError(400, 'Variables are invalid JSON.'); + } + } + + // Shallow clone context for queries in batches. This allows + // users to distinguish multiple queries in the batch and to + // modify the context object without interfering with each other. + let context = optionsObject.context; + if (isBatch) { + context = Object.assign({}, context || {}); + } + + let params = { + schema: optionsObject.schema, + query: query, + variables: variables, + context: context, + rootValue: optionsObject.rootValue, + operationName: operationName, + logFunction: optionsObject.logFunction, + validationRules: optionsObject.validationRules, + formatError: formatErrorFn, + formatResponse: optionsObject.formatResponse, + debug: optionsObject.debug, + }; + + if (optionsObject.formatParams) { + params = optionsObject.formatParams(params); + } + + responses.push(await runQuery(params)); + } catch (e) { + // Populate any HttpQueryError to our handler which should + // convert it to Http Error. + if ( e.name === 'HttpQueryError' ) { + throw e; + } + + responses.push({ errors: [formatErrorFn(e)] }); + } + } + + if (!isBatch) { + const gqlResponse = responses[0]; + if (gqlResponse.errors && typeof gqlResponse.data === 'undefined') { + throw new HttpQueryError(400, JSON.stringify(gqlResponse), true, { + 'Content-Type': 'application/json', + }); + } + return JSON.stringify(gqlResponse); + } + + return JSON.stringify(responses); +} diff --git a/packages/graphql-server-express/package.json b/packages/graphql-server-express/package.json index 3fda01eb991..bf204401d89 100644 --- a/packages/graphql-server-express/package.json +++ b/packages/graphql-server-express/package.json @@ -37,6 +37,7 @@ "graphql-server-integration-testsuite": "^0.5.0", "body-parser": "^1.15.2", "connect": "^3.4.1", + "connect-query": "^0.2.0", "express": "^4.14.0", "multer": "^1.2.0" }, diff --git a/packages/graphql-server-express/src/apolloServerHttp.test.ts b/packages/graphql-server-express/src/apolloServerHttp.test.ts index 217c9a8287d..03528cac3bf 100644 --- a/packages/graphql-server-express/src/apolloServerHttp.test.ts +++ b/packages/graphql-server-express/src/apolloServerHttp.test.ts @@ -372,11 +372,11 @@ describe(`GraphQL-HTTP (apolloServer) tests for ${version} express`, () => { app.use(urlString(), graphqlExpress({ schema: TestSchema })); const response = await request(app) - .get(urlString({ query: '{test}' })); + .put(urlString({ query: '{test}' })); expect(response.status).to.equal(405); - expect(response.headers.allow).to.equal('POST'); - return expect(response.text).to.contain('Apollo Server supports only POST requests.'); + expect(response.headers.allow).to.equal('GET, POST'); + return expect(response.text).to.contain('Apollo Server supports only GET/POST requests.'); }); }); diff --git a/packages/graphql-server-express/src/connectApollo.test.ts b/packages/graphql-server-express/src/connectApollo.test.ts index a7a9be333c4..89e77a2b268 100644 --- a/packages/graphql-server-express/src/connectApollo.test.ts +++ b/packages/graphql-server-express/src/connectApollo.test.ts @@ -15,6 +15,7 @@ function createConnectApp(options: CreateAppOptions = {}) { if (options.graphiqlOptions ) { app.use('/graphiql', graphiqlConnect( options.graphiqlOptions )); } + app.use('/graphql', require('connect-query')()); app.use('/graphql', graphqlConnect( options.graphqlOptions )); return app; } diff --git a/packages/graphql-server-express/src/expressApollo.ts b/packages/graphql-server-express/src/expressApollo.ts index bdebad5b107..048f6df6128 100644 --- a/packages/graphql-server-express/src/expressApollo.ts +++ b/packages/graphql-server-express/src/expressApollo.ts @@ -1,7 +1,6 @@ import * as express from 'express'; -import * as graphql from 'graphql'; import * as url from 'url'; -import { GraphQLOptions, runQuery } from 'graphql-server-core'; +import { GraphQLOptions, HttpQueryError, runHttpQuery } from 'graphql-server-core'; import * as GraphiQL from 'graphql-server-module-graphiql'; export interface ExpressGraphQLOptionsFunction { @@ -27,116 +26,33 @@ export function graphqlExpress(options: GraphQLOptions | ExpressGraphQLOptionsFu throw new Error(`Apollo Server expects exactly one argument, got ${arguments.length}`); } - return async (req: express.Request, res: express.Response, next) => { - let optionsObject: GraphQLOptions; - if (isOptionsFunction(options)) { - try { - optionsObject = await options(req, res); - } catch (e) { - res.statusCode = 500; - res.write(`Invalid options provided to ApolloServer: ${e.message}`); - res.end(); - } - } else { - optionsObject = options; - } - - const formatErrorFn = optionsObject.formatError || graphql.formatError; - - if (req.method !== 'POST') { - res.setHeader('Allow', 'POST'); - res.statusCode = 405; - res.write('Apollo Server supports only POST requests.'); - res.end(); - return; - } - - if (!req.body) { - res.statusCode = 500; - res.write('POST body missing. Did you forget "app.use(bodyParser.json())"?'); + return (req: express.Request, res: express.Response): void => { + runHttpQuery([req, res], { + method: req.method, + options: options, + query: req.method === 'POST' ? req.body : req.query, + }).then((gqlResponse) => { + res.setHeader('Content-Type', 'application/json'); + res.write(gqlResponse); res.end(); - return; - } - - let b = req.body; - let isBatch = true; - // TODO: do something different here if the body is an array. - // Throw an error if body isn't either array or object. - if (!Array.isArray(b)) { - isBatch = false; - b = [b]; - } - - let responses: Array = []; - for (let requestParams of b) { - try { - const query = requestParams.query; - const operationName = requestParams.operationName; - let variables = requestParams.variables; - - if (typeof variables === 'string') { - try { - variables = JSON.parse(variables); - } catch (error) { - res.statusCode = 400; - res.write('Variables are invalid JSON.'); - res.end(); - return; - } - } - - // Shallow clone context for queries in batches. This allows - // users to distinguish multiple queries in the batch and to - // modify the context object without interfering with each other. - let context = optionsObject.context; - if (isBatch) { - context = Object.assign({}, context || {}); - } - - let params = { - schema: optionsObject.schema, - query: query, - variables: variables, - context: context, - rootValue: optionsObject.rootValue, - operationName: operationName, - logFunction: optionsObject.logFunction, - validationRules: optionsObject.validationRules, - formatError: formatErrorFn, - formatResponse: optionsObject.formatResponse, - debug: optionsObject.debug, - }; - - if (optionsObject.formatParams) { - params = optionsObject.formatParams(params); - } - - responses.push(await runQuery(params)); - } catch (e) { - responses.push({ errors: [formatErrorFn(e)] }); + }, (error: HttpQueryError) => { + if ( 'HttpQueryError' !== error.name ) { + throw error; } - } - res.setHeader('Content-Type', 'application/json'); - if (isBatch) { - res.write(JSON.stringify(responses)); - res.end(); - } else { - const gqlResponse = responses[0]; - if (gqlResponse.errors && typeof gqlResponse.data === 'undefined') { - res.statusCode = 400; + if ( error.headers ) { + Object.keys(error.headers).forEach((header) => { + res.setHeader(header, error.headers[header]); + }); } - res.write(JSON.stringify(gqlResponse)); - res.end(); - } + res.statusCode = error.statusCode; + res.write(error.message); + res.end(); + }); }; } -function isOptionsFunction(arg: GraphQLOptions | ExpressGraphQLOptionsFunction): arg is ExpressGraphQLOptionsFunction { - return typeof arg === 'function'; -} - /* This middleware returns the html for the GraphiQL interactive query UI * * GraphiQLData arguments @@ -149,7 +65,7 @@ function isOptionsFunction(arg: GraphQLOptions | ExpressGraphQLOptionsFunction): */ export function graphiqlExpress(options: GraphiQL.GraphiQLData) { - return (req: express.Request, res: express.Response, next) => { + return (req: express.Request, res: express.Response) => { const q = req.url && url.parse(req.url, true).query || {}; const query = q.query || ''; const variables = q.variables || '{}'; diff --git a/packages/graphql-server-hapi/src/hapiApollo.test.ts b/packages/graphql-server-hapi/src/hapiApollo.test.ts index 3f14fb3d3a2..7540b68833a 100644 --- a/packages/graphql-server-hapi/src/hapiApollo.test.ts +++ b/packages/graphql-server-hapi/src/hapiApollo.test.ts @@ -1,10 +1,10 @@ import * as hapi from 'hapi'; -import { graphqlHapi, graphiqlHapi, HapiPluginOptions } from './hapiApollo'; +import { graphqlHapi, graphiqlHapi } from './hapiApollo'; import 'mocha'; -import testSuite, { Schema } from 'graphql-server-integration-testsuite'; +import testSuite, { Schema, CreateAppOptions } from 'graphql-server-integration-testsuite'; -function createApp(createOptions: HapiPluginOptions) { +function createApp(options: CreateAppOptions) { const server = new hapi.Server(); server.connection({ @@ -15,7 +15,7 @@ function createApp(createOptions: HapiPluginOptions) { server.register({ register: graphqlHapi, options: { - graphqlOptions: createOptions ? createOptions.graphqlOptions : { schema: Schema }, + graphqlOptions: (options && options.graphqlOptions) || { schema: Schema }, path: '/graphql', }, }); diff --git a/packages/graphql-server-hapi/src/hapiApollo.ts b/packages/graphql-server-hapi/src/hapiApollo.ts index d1f0482b041..0973707077e 100644 --- a/packages/graphql-server-hapi/src/hapiApollo.ts +++ b/packages/graphql-server-hapi/src/hapiApollo.ts @@ -1,8 +1,7 @@ import * as Boom from 'boom'; -import { Server, Request, IReply } from 'hapi'; -import { ExecutionResult, formatError } from 'graphql'; +import { Server, Response, Request, IReply } from 'hapi'; import * as GraphiQL from 'graphql-server-module-graphiql'; -import { GraphQLOptions, runQuery } from 'graphql-server-core'; +import { GraphQLOptions, runHttpQuery, HttpQueryError } from 'graphql-server-core'; export interface IRegister { (server: Server, options: any, next: any): void; @@ -19,48 +18,48 @@ export interface HapiPluginOptions { graphqlOptions: GraphQLOptions | HapiOptionsFunction; } -const graphqlHapi: IRegister = function(server: Server, options: HapiPluginOptions, next) { - server.method('verifyPayload', verifyPayload); - server.method('getGraphQLParams', getGraphQLParams); - server.method('getGraphQLOptions', getGraphQLOptions); - server.method('processQuery', processQuery); +function runHttpQueryWrapper(options: GraphQLOptions | HapiOptionsFunction, request: Request, reply: IReply): Promise { + return runHttpQuery([request], { + method: request.method.toUpperCase(), + options: options, + query: request.method === 'post' ? request.payload : request.query, + }).then((gqlResponse) => { + return reply(gqlResponse).type('application/json'); + }, (error: HttpQueryError) => { + if ( 'HttpQueryError' !== error.name ) { + throw error; + } - const config = Object.assign(options.route || {}, { - plugins: { - graphql: isOptionsFunction(options.graphqlOptions) ? options.graphqlOptions : () => options.graphqlOptions, - }, - pre: [{ - assign: 'isBatch', - method: 'verifyPayload(payload)', - }, { - assign: 'graphqlParams', - method: 'getGraphQLParams(payload, pre.isBatch)', - }, { - assign: 'graphqlOptions', - method: 'getGraphQLOptions', - }, { - assign: 'graphQL', - method: 'processQuery(pre.graphqlParams, pre.graphqlOptions, pre.isBatch)', - }], + if ( true === error.isGraphQLError ) { + return reply(error.message).code(error.statusCode).type('application/json'); + } + + const err = Boom.create(error.statusCode); + err.output.payload.message = error.message; + if ( error.headers ) { + Object.keys(error.headers).forEach((header) => { + err.output.headers[header] = error.headers[header]; + }); + } + + return reply(err); }); +} + +const graphqlHapi: IRegister = function(server: Server, options: HapiPluginOptions, next) { + if (!options || !options.graphqlOptions) { + throw new Error('Apollo Server requires options.'); + } + + if (arguments.length !== 3) { + throw new Error(`Apollo Server expects exactly 3 argument, got ${arguments.length}`); + } server.route({ - method: 'POST', + method: ['GET', 'POST'], path: options.path || '/graphql', - config, - handler: function(request, reply) { - const responses = request.pre['graphQL']; - if (request.pre['isBatch']) { - return reply(responses); - } else { - const gqlResponse = responses[0]; - if (gqlResponse.errors && typeof gqlResponse.data === 'undefined') { - return reply(gqlResponse).code(400); - } else { - return reply(gqlResponse); - } - } - }, + config: options.route || {}, + handler: (request, reply) => runHttpQueryWrapper(options.graphqlOptions, request, reply), }); return next(); @@ -71,107 +70,6 @@ graphqlHapi.attributes = { version: '0.0.1', }; -function verifyPayload(payload, reply) { - if (!payload) { - return reply(createErr(500, 'POST body missing.')); - } - - // TODO: do something different here if the body is an array. - // Throw an error if body isn't either array or object. - reply(payload && Array.isArray(payload)); -} - -function getGraphQLParams(payload, isBatch, reply) { - if (!isBatch) { - payload = [payload]; - } - - const params = []; - for (let query of payload) { - let variables = query.variables; - if (variables && typeof variables === 'string') { - try { - variables = JSON.parse(variables); - } catch (error) { - return reply(createErr(400, 'Variables are invalid JSON.')); - } - } - - params.push({ - query: query.query, - variables: variables, - operationName: query.operationName, - }); - } - reply(params); -}; - -async function getGraphQLOptions(request: Request, reply: IReply): Promise<{}> { - const options = request.route.settings.plugins['graphql']; - let optionsObject: GraphQLOptions; - if (isOptionsFunction(options)) { - try { - const opsFunc: HapiOptionsFunction = options; - optionsObject = await opsFunc(request); - } catch (e) { - return reply(createErr(500, `Invalid options provided to ApolloServer: ${e.message}`)); - } - } else { - optionsObject = options; - } - reply(optionsObject); -} - -async function processQuery(graphqlParams, optionsObject: GraphQLOptions, isBatch: boolean, reply) { - const formatErrorFn = optionsObject.formatError || formatError; - - let responses: ExecutionResult[] = []; - for (let query of graphqlParams) { - try { - // Shallow clone context for queries in batches. This allows - // users to distinguish multiple queries in the batch and to - // modify the context object without interfering with each other. - let context = optionsObject.context; - if (isBatch) { - context = Object.assign({}, context || {}); - } - - let params = { - schema: optionsObject.schema, - query: query.query, - variables: query.variables, - rootValue: optionsObject.rootValue, - context: context, - operationName: query.operationName, - logFunction: optionsObject.logFunction, - validationRules: optionsObject.validationRules, - formatError: formatErrorFn, - formatResponse: optionsObject.formatResponse, - debug: optionsObject.debug, - }; - - if (optionsObject.formatParams) { - params = optionsObject.formatParams(params); - } - - responses.push(await runQuery(params)); - } catch (e) { - responses.push({ errors: [formatErrorFn(e)] }); - } - } - return reply(responses); -} - -function isOptionsFunction(arg: GraphQLOptions | HapiOptionsFunction): arg is HapiOptionsFunction { - return typeof arg === 'function'; -} - -function createErr(code: number, message: string) { - const err = Boom.create(code); - err.output.payload.message = message; - return err; -} - export interface GraphiQLPluginOptions { path: string; route?: any; diff --git a/packages/graphql-server-integration-testsuite/src/index.ts b/packages/graphql-server-integration-testsuite/src/index.ts index e0319a0b826..0ab809014b7 100644 --- a/packages/graphql-server-integration-testsuite/src/index.ts +++ b/packages/graphql-server-integration-testsuite/src/index.ts @@ -1,12 +1,14 @@ import { expect } from 'chai'; import { stub } from 'sinon'; import 'mocha'; +import * as querystring from 'querystring'; import { GraphQLSchema, GraphQLObjectType, GraphQLString, GraphQLError, + GraphQLNonNull, introspectionQuery, BREAK, } from 'graphql'; @@ -59,6 +61,18 @@ const QueryType = new GraphQLObjectType({ }, }); +const PersonType = new GraphQLObjectType({ + name: 'PersonType', + fields: { + firstName: { + type: GraphQLString, + }, + lastName: { + type: GraphQLString, + }, + }, +}); + const MutationType = new GraphQLObjectType({ name: 'MutationType', fields: { @@ -69,6 +83,20 @@ const MutationType = new GraphQLObjectType({ return `not really a mutation, but who cares: ${echo}`; }, }, + testPerson: { + type: PersonType, + args: { + firstName: { + type: new GraphQLNonNull(GraphQLString), + }, + lastName: { + type: new GraphQLNonNull(GraphQLString), + }, + }, + resolve(root, args) { + return args; + }, + }, }, }); @@ -158,15 +186,14 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { }); }); - it('rejects the request if the method is not POST', () => { + it('rejects the request if the method is not POST or GET', () => { app = createApp({excludeParser: true}); const req = request(app) - .get('/graphql') + .head('/graphql') .send(); return req.then((res) => { - expect(res.status).to.be.oneOf([404, 405]); - // Hapi doesn't return allow header, so we can't test this. - // return expect(res.headers['allow']).to.equal('POST'); + expect(res.status).to.equal(405); + expect(res.headers['allow']).to.equal('GET, POST'); }); }); @@ -181,6 +208,101 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { }); }); + it('throws an error if GET query is missing', () => { + app = createApp(); + const req = request(app) + .get(`/graphql`); + return req.then((res) => { + expect(res.status).to.equal(400); + return expect(res.error.text).to.contain('GET query missing.'); + }); + }); + + it('can handle a basic GET request', () => { + app = createApp(); + const expected = { + testString: 'it works', + }; + const query = { + query: 'query test{ testString }', + }; + const req = request(app) + .get(`/graphql?${querystring.stringify(query)}`); + return req.then((res) => { + expect(res.status).to.equal(200); + return expect(res.body.data).to.deep.equal(expected); + }); + }); + + it('can handle a basic implicit GET request', () => { + app = createApp(); + const expected = { + testString: 'it works', + }; + const query = { + query: '{ testString }', + }; + const req = request(app) + .get(`/graphql?${querystring.stringify(query)}`); + return req.then((res) => { + expect(res.status).to.equal(200); + return expect(res.body.data).to.deep.equal(expected); + }); + }); + + it('throws error if trying to use mutation using GET request', () => { + app = createApp(); + const query = { + query: 'mutation test{ testMutation(echo: "ping") }', + }; + const req = request(app) + .get(`/graphql?${querystring.stringify(query)}`); + return req.then((res) => { + expect(res.status).to.equal(405); + expect(res.headers['allow']).to.equal('POST'); + return expect(res.error.text).to.contain('GET supports only query operation'); + }); + }); + + it('throws error if trying to use mutation with fragment using GET request', () => { + app = createApp(); + const query = { + query: `fragment PersonDetails on PersonType { + firstName + } + + mutation test { + testPerson(firstName: "Test", lastName: "Me") { + ...PersonDetails + } + }`, + }; + const req = request(app) + .get(`/graphql?${querystring.stringify(query)}`); + return req.then((res) => { + expect(res.status).to.equal(405); + expect(res.headers['allow']).to.equal('POST'); + return expect(res.error.text).to.contain('GET supports only query operation'); + }); + }); + + it('can handle a GET request with variables', () => { + app = createApp(); + const query = { + query: 'query test($echo: String){ testArgument(echo: $echo) }', + variables: JSON.stringify({ echo: 'world' }), + }; + const expected = { + testArgument: 'hello world', + }; + const req = request(app) + .get(`/graphql?${querystring.stringify(query)}`); + return req.then((res) => { + expect(res.status).to.equal(200); + return expect(res.body.data).to.deep.equal(expected); + }); + }); + it('can handle a basic request', () => { app = createApp(); const expected = { diff --git a/packages/graphql-server-koa/src/koaApollo.test.ts b/packages/graphql-server-koa/src/koaApollo.test.ts index a044652ea72..e75a538363b 100644 --- a/packages/graphql-server-koa/src/koaApollo.test.ts +++ b/packages/graphql-server-koa/src/koaApollo.test.ts @@ -20,6 +20,7 @@ function createApp(options: CreateAppOptions = {}) { if (options.graphiqlOptions ) { router.get('/graphiql', graphiqlKoa( options.graphiqlOptions )); } + router.get('/graphql', graphqlKoa( options.graphqlOptions )); router.post('/graphql', graphqlKoa( options.graphqlOptions )); app.use(router.routes()); app.use(router.allowedMethods()); diff --git a/packages/graphql-server-koa/src/koaApollo.ts b/packages/graphql-server-koa/src/koaApollo.ts index f7683eb144b..759c6492f85 100644 --- a/packages/graphql-server-koa/src/koaApollo.ts +++ b/packages/graphql-server-koa/src/koaApollo.ts @@ -1,6 +1,5 @@ import * as koa from 'koa'; -import * as graphql from 'graphql'; -import { GraphQLOptions, runQuery } from 'graphql-server-core'; +import { GraphQLOptions, HttpQueryError, runHttpQuery } from 'graphql-server-core'; import * as GraphiQL from 'graphql-server-module-graphiql'; export interface KoaGraphQLOptionsFunction { @@ -20,101 +19,33 @@ export function graphqlKoa(options: GraphQLOptions | KoaGraphQLOptionsFunction): throw new Error(`Apollo Server expects exactly one argument, got ${arguments.length}`); } - return async (ctx, next) => { - let optionsObject: GraphQLOptions; - if (isOptionsFunction(options)) { - try { - optionsObject = await options(ctx); - } catch (e) { - ctx.status = 500; - return ctx.body = `Invalid options provided to ApolloServer: ${e.message}`; + return (ctx: koa.Context): Promise => { + return runHttpQuery([ctx], { + method: ctx.request.method, + options: options, + query: ctx.request.method === 'POST' ? ctx.request.body : ctx.request.query, + }).then((gqlResponse) => { + ctx.set('Content-Type', 'application/json'); + ctx.body = gqlResponse; + }, (error: HttpQueryError) => { + if ( 'HttpQueryError' !== error.name ) { + throw error; } - } else { - optionsObject = options; - } - const formatErrorFn = optionsObject.formatError || graphql.formatError; - - if (!ctx.request.body) { - ctx.status = 500; - return ctx.body = 'POST body missing. Did you forget "app.use(koaBody())"?'; - } - - let b = ctx.request.body; - let isBatch = true; - if (!Array.isArray(b)) { - isBatch = false; - b = [b]; - } - - let responses: Array = []; - for (let requestParams of b) { - try { - const query = requestParams.query; - const operationName = requestParams.operationName; - let variables = requestParams.variables; - - if (typeof variables === 'string') { - try { - variables = JSON.parse(variables); - } catch (error) { - ctx.status = 400; - return ctx.body = 'Variables are invalid JSON.'; - } - } - - // Shallow clone context for queries in batches. This allows - // users to distinguish multiple queries in the batch and to - // modify the context object without interfering with each other. - let context = optionsObject.context; - if (isBatch) { - context = Object.assign({}, context || {}); - } - - let params = { - schema: optionsObject.schema, - query: query, - variables: variables, - context: context, - rootValue: optionsObject.rootValue, - operationName: operationName, - logFunction: optionsObject.logFunction, - validationRules: optionsObject.validationRules, - formatError: formatErrorFn, - formatResponse: optionsObject.formatResponse, - debug: optionsObject.debug, - }; - - if (optionsObject.formatParams) { - params = optionsObject.formatParams(params); - } - - responses.push(await runQuery(params)); - } catch (e) { - responses.push({ errors: [formatErrorFn(e)] }); - } - } - - ctx.set('Content-Type', 'application/json'); - if (isBatch) { - return ctx.body = JSON.stringify(responses); - } else { - const gqlResponse = responses[0]; - if (gqlResponse.errors && typeof gqlResponse.data === 'undefined') { - ctx.status = 400; + if ( error.headers ) { + Object.keys(error.headers).forEach((header) => { + ctx.set(header, error.headers[header]); + }); } - return ctx.body = JSON.stringify(gqlResponse); - } + ctx.status = error.statusCode; + ctx.body = error.message; + }); }; } -function isOptionsFunction(arg: GraphQLOptions | KoaGraphQLOptionsFunction): arg is KoaGraphQLOptionsFunction { - return typeof arg === 'function'; -} - export function graphiqlKoa(options: GraphiQL.GraphiQLData) { - return (ctx, next) => { + return (ctx: koa.Context) => { const q = ctx.request.query || {}; const query = q.query || '';