diff --git a/CHANGELOG.md b/CHANGELOG.md index bd5ff583edd..a90ae785493 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +### v0.3.0 +* Refactor HAPI integration to improve the API and make the plugins more idiomatic. ([@nnance](https://github.com/nnance)) in +[PR #127](https://github.com/apollostack/apollo-server/pull/127) +* Fixed query batching with HAPI integration. Issue #123 ([@nnance](https://github.com/nnance)) in +[PR #127](https://github.com/apollostack/apollo-server/pull/127) +* Add support for route options in HAPI integration. Issue #97. ([@nnance](https://github.com/nnance)) in +[PR #127](https://github.com/apollostack/apollo-server/pull/127) + +### v0.2.6 * Expose the OperationStore as part of the public API. ([@nnance](https://github.com/nnance)) * Support adding parsed operations to the OperationStore. ([@nnance](https://github.com/nnance)) * Expose ApolloOptions as part of the public API. diff --git a/README.md b/README.md index ee86f8397aa..5a54eae8acf 100644 --- a/README.md +++ b/README.md @@ -25,11 +25,11 @@ Apollo Server is super-easy to set up. Just npm-install apollo-server, write a G ### TypeScript -If you want to build your GraphQL server using TypeScript, Apollo Server is the project for you. +If you want to build your GraphQL server using TypeScript, Apollo Server is the project for you. **NOTE**: All typings mentioned below must be included in your project in order for it to compile. ```sh npm install apollo-server -typings i -SG dt~express dt~express-serve-static-core dt~serve-static dt~mime dt~hapi dt~cookies dt~koa +typings i -SG dt~express dt~express-serve-static-core dt~serve-static dt~mime dt~hapi dt~boom dt~cookies dt~koa ``` For using the project in JavaScript, just run `npm install --save apollo-server` and you're good to go! @@ -64,7 +64,10 @@ app.use('/graphql', bodyParser.json(), apolloConnect({ schema: myGraphQLSchema } app.listen(PORT); ``` -### hapi +### HAPI + +Now with the HAPI plugins `ApolloHAPI` and `GraphiQLHAPI` you can pass a route object that includes options to be applied to the route. The example below enables CORS on the `/graphql` route. + ```js import hapi from 'hapi'; import { ApolloHAPI } from 'apollo-server'; @@ -80,9 +83,16 @@ server.connection({ }); server.register({ - register: new ApolloHAPI(), - options: { schema: myGraphQLSchema }, - routes: { prefix: '/graphql' }, + register: ApolloHAPI, + options: { + path: '/graphql', + apolloOptions: { + schema: myGraphQLSchema, + }, + route: { + cors: true + } + }, }); server.start((err) => { @@ -92,6 +102,7 @@ server.start((err) => { console.log(`Server running at: ${server.info.uri}`); }); ``` + ### Koa ```js import koa from 'koa'; diff --git a/package.json b/package.json index 832936e1b72..72d0a7ec5e3 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "babel-polyfill": "^6.9.1", "babel-preset-es2015": "^6.9.0", "body-parser": "^1.15.2", + "boom": "^4.0.0", "chai": "^3.5.0", "connect": "^3.4.1", "express": "^4.14.0", diff --git a/src/integrations/hapiApollo.test.ts b/src/integrations/hapiApollo.test.ts index ca0df5d121d..0a5e548b289 100644 --- a/src/integrations/hapiApollo.test.ts +++ b/src/integrations/hapiApollo.test.ts @@ -1,9 +1,9 @@ import * as hapi from 'hapi'; -import { ApolloHAPI, GraphiQLHAPI } from './hapiApollo'; +import { ApolloHAPI, GraphiQLHAPI, HAPIPluginOptions } from './hapiApollo'; -import testSuite, { Schema, CreateAppOptions } from './integrations.test'; +import testSuite, { Schema } from './integrations.test'; -function createApp(options: CreateAppOptions = {}) { +function createApp(createOptions: HAPIPluginOptions) { const server = new hapi.Server(); server.connection({ @@ -11,18 +11,22 @@ function createApp(options: CreateAppOptions = {}) { port: 8000, }); - options.apolloOptions = options.apolloOptions || { schema: Schema }; - server.register({ - register: new ApolloHAPI(), - options: options.apolloOptions, - routes: { prefix: '/graphql' }, + register: ApolloHAPI, + options: { + apolloOptions: createOptions ? createOptions.apolloOptions : { schema: Schema }, + path: '/graphql', + }, }); server.register({ - register: new GraphiQLHAPI(), - options: { endpointURL: '/graphql' }, - routes: { prefix: '/graphiql' }, + register: GraphiQLHAPI, + options: { + path: '/graphiql', + graphiqlOptions: { + endpointURL: '/graphql', + }, + }, }); return server.listener; diff --git a/src/integrations/hapiApollo.ts b/src/integrations/hapiApollo.ts index cba1d143e85..b8e3c51e748 100644 --- a/src/integrations/hapiApollo.ts +++ b/src/integrations/hapiApollo.ts @@ -1,127 +1,142 @@ -import * as hapi from 'hapi'; -import * as graphql from 'graphql'; +import * as Boom from 'boom'; +import { Server, Request, IReply } from 'hapi'; +import { GraphQLResult, formatError } from 'graphql'; import * as GraphiQL from '../modules/renderGraphiQL'; import { runQuery } from '../core/runQuery'; import ApolloOptions from './apolloOptions'; export interface IRegister { - (server: hapi.Server, options: any, next: any): void; + (server: Server, options: any, next: any): void; attributes?: any; } export interface HAPIOptionsFunction { - (req?: hapi.Request): ApolloOptions | Promise; + (req?: Request): ApolloOptions | Promise; } -export class ApolloHAPI { - constructor() { - this.register.attributes = { - name: 'graphql', - version: '0.0.1', - }; - } - - public register: IRegister = (server: hapi.Server, options: ApolloOptions | HAPIOptionsFunction, next) => { - server.route({ - method: 'POST', - path: '/', - handler: async (request, reply) => { - let optionsObject: ApolloOptions; - if (isOptionsFunction(options)) { - try { - optionsObject = await options(request); - } catch (e) { - reply(`Invalid options provided to ApolloServer: ${e.message}`).code(500); - } - } else { - optionsObject = options; - } - - if (!request.payload) { - reply('POST body missing.').code(500); - return; - } - - const responses = await processQuery(request.payload, optionsObject); - - if (responses.length > 1) { - reply(responses); - } else { - const gqlResponse = responses[0]; - if (gqlResponse.errors && typeof gqlResponse.data === 'undefined') { - reply(gqlResponse).code(400); - } else { - reply(gqlResponse); - } - } - - }, - }); - next(); - } +export interface HAPIPluginOptions { + path: string; + route?: any; + apolloOptions: ApolloOptions | HAPIOptionsFunction; } -export class GraphiQLHAPI { - constructor() { - this.register.attributes = { - name: 'graphiql', - version: '0.0.1', - }; - } +const ApolloHAPI: IRegister = function(server: Server, options: HAPIPluginOptions, next) { + server.method('verifyPayload', verifyPayload); + server.method('getGraphQLParams', getGraphQLParams); + server.method('getApolloOptions', getApolloOptions); + server.method('processQuery', processQuery); + + const config = Object.assign(options.route || {}, { + plugins: { + graphql: options.apolloOptions, + }, + pre: [{ + assign: 'isBatch', + method: 'verifyPayload(payload)', + }, { + assign: 'graphqlParams', + method: 'getGraphQLParams(payload, pre.isBatch)', + }, { + assign: 'apolloOptions', + method: 'getApolloOptions', + }, { + assign: 'graphQL', + method: 'processQuery(pre.graphqlParams, pre.apolloOptions)', + }], + }); + + server.route({ + method: '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); + } + } + }, + }); - public register: IRegister = (server: hapi.Server, options: GraphiQL.GraphiQLData, next) => { - server.route({ - method: 'GET', - path: '/', - handler: (request, reply) => { - const q = request.query || {}; - const query = q.query || ''; - const variables = q.variables || '{}'; - const operationName = q.operationName || ''; - - const graphiQLString = GraphiQL.renderGraphiQL({ - endpointURL: options.endpointURL, - query: query || options.query, - variables: JSON.parse(variables) || options.variables, - operationName: operationName || options.operationName, - }); - reply(graphiQLString).header('Content-Type', 'text/html'); - }, - }); - next(); - } -} + return next(); +}; -async function processQuery(body, optionsObject) { - const formatErrorFn = optionsObject.formatError || graphql.formatError; +ApolloHAPI.attributes = { + name: 'graphql', + version: '0.0.1', +}; + +function verifyPayload(payload, reply) { + if (!payload) { + return reply(createErr(500, 'POST body missing.')); + } - 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(body)) { - isBatch = false; - body = [body]; - } + reply(payload && Array.isArray(payload)); +} - let responses: Array = []; - for (let payload of body) { - try { - const operationName = payload.operationName; - let variables = payload.variables; +function getGraphQLParams(payload, isBatch, reply) { + if (!isBatch) { + payload = [payload]; + } - if (typeof variables === 'string') { - // TODO: catch errors + 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 getApolloOptions(request: Request, reply: IReply): Promise<{}> { + const options = request.route.settings.plugins['graphql']; + let optionsObject: ApolloOptions; + 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: ApolloOptions, reply) { + const formatErrorFn = optionsObject.formatError || formatError; + + let responses: GraphQLResult[] = []; + for (let query of graphqlParams) { + try { let params = { schema: optionsObject.schema, - query: payload.query, - variables: variables, + query: query.query, + variables: query.variables, rootValue: optionsObject.rootValue, context: optionsObject.context, - operationName: operationName, + operationName: query.operationName, logFunction: optionsObject.logFunction, validationRules: optionsObject.validationRules, formatError: formatErrorFn, @@ -137,9 +152,75 @@ async function processQuery(body, optionsObject) { responses.push({ errors: [formatErrorFn(e)] }); } } - return responses; + return reply(responses); } function isOptionsFunction(arg: ApolloOptions | 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; + graphiqlOptions: GraphiQL.GraphiQLData; +} + +const GraphiQLHAPI: IRegister = function(server: Server, options: GraphiQLPluginOptions, next) { + server.method('getGraphiQLParams', getGraphiQLParams); + server.method('renderGraphiQL', renderGraphiQL); + + const config = Object.assign(options.route || {}, { + plugins: { + graphiql: options.graphiqlOptions, + }, + pre: [{ + assign: 'graphiqlParams', + method: 'getGraphiQLParams', + }, { + assign: 'graphiQLString', + method: 'renderGraphiQL(route, pre.graphiqlParams)', + }], + }); + + server.route({ + method: 'GET', + path: options.path || '/graphql', + config, + handler: (request, reply) => { + reply(request.pre.graphiQLString).header('Content-Type', 'text/html'); + }, + }); + next(); +}; + +GraphiQLHAPI.attributes = { + name: 'graphiql', + version: '0.0.1', +}; + +function getGraphiQLParams(request, reply) { + const q = request.query || {}; + const query = q.query || ''; + const variables = q.variables || '{}'; + const operationName = q.operationName || ''; + reply({ query, variables, operationName}); +} + +function renderGraphiQL(route, graphiqlParams: any, reply) { + const graphiqlOptions = route.settings.plugins['graphiql']; + const graphiQLString = GraphiQL.renderGraphiQL({ + endpointURL: graphiqlOptions.endpointURL, + query: graphiqlParams.query || graphiqlOptions.query, + variables: JSON.parse(graphiqlParams.variables) || graphiqlOptions.variables, + operationName: graphiqlParams.operationName || graphiqlOptions.operationName, + }); + reply(graphiQLString); +} + +export { ApolloHAPI, GraphiQLHAPI }; diff --git a/src/integrations/integrations.test.ts b/src/integrations/integrations.test.ts index f7e18cd9880..1e4edc28784 100644 --- a/src/integrations/integrations.test.ts +++ b/src/integrations/integrations.test.ts @@ -283,6 +283,30 @@ export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => { }); }); + it('can handle batch requests', () => { + app = createApp(); + const expected = [ + { + data: { + testString: 'it works', + }, + }, + ]; + const req = request(app) + .post('/graphql') + .send([{ + query: ` + query test($echo: String){ testArgument(echo: $echo) } + query test2{ testString }`, + variables: { echo: 'world' }, + operationName: 'test2', + }]); + return req.then((res) => { + expect(res.status).to.equal(200); + return expect(res.body).to.deep.equal(expected); + }); + }); + it('can handle a request with a mutation', () => { app = createApp(); const expected = { diff --git a/typings.json b/typings.json index ddc134108c2..780cd73f541 100644 --- a/typings.json +++ b/typings.json @@ -6,6 +6,7 @@ }, "globalDependencies": { "body-parser": "registry:dt/body-parser#0.0.0+20160619023215", + "boom": "registry:dt/boom#0.0.0+20160724101333", "connect": "registry:dt/connect#3.4.0+20160317120654", "cookies": "registry:dt/cookies#0.5.1+20160316171810", "express": "registry:dt/express#4.0.0+20160708185218",