Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support for GET method for existing servers #180

Merged
merged 13 commits into from
Jan 19, 2017
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 }));

Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion packages/graphql-server-core/src/graphqlOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -28,3 +28,7 @@ interface GraphQLServerOptions {
}

export default GraphQLServerOptions;

export function isOptionsFunction(arg: GraphQLServerOptions | Function): arg is Function {
return typeof arg === 'function';
}
3 changes: 2 additions & 1 deletion packages/graphql-server-core/src/index.ts
Original file line number Diff line number Diff line change
@@ -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'
136 changes: 136 additions & 0 deletions packages/graphql-server-core/src/runHttpQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { 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.statusCode = statusCode;
this.isGraphQLError = isGraphQLError;
this.headers = headers;
}
}

export async function runHttpQuery(handlerArguments: Array<any>, request: HttpQueryRequest): Promise<string> {
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<ExecutionResult> = [];
for (let requestParams of requestPayload) {
if ( isGetRequest && !requestParams.query.trim().startsWith('query')) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to the spec the query keyword is optional. I know it's annoying, but that's how it is. So we'll have to check here whether it's an anonymous query that starts with {.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, the query isn't necessarily the first thing in the document right? For example:

fragment X on Y { ... }

{ ... }

Is also a valid query.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would check for "document doesn't contain the keyword mutation outside of all brackets"

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@helfer => i was already figured it out, and actually, you were reviewing outdated chunk, which this was already fixed.
@stubailo => about the fragment, this is really usecase i didn't take into consideration,
i'll try to use GraphQL Parser instead to get the exact operation GraphQL will try to execute.

const errorMsg = `GET supports only query operation`;
throw new HttpQueryError(405, errorMsg, false, {
'Allow': 'POST',
});
}

try {
const query = requestParams.query;
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) {
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);
}
1 change: 1 addition & 0 deletions packages/graphql-server-express/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
6 changes: 3 additions & 3 deletions packages/graphql-server-express/src/apolloServerHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
});
});

Expand Down
1 change: 1 addition & 0 deletions packages/graphql-server-express/src/connectApollo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
126 changes: 21 additions & 105 deletions packages/graphql-server-express/src/expressApollo.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<graphql.ExecutionResult> = [];
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 ( undefined === error.statusCode ) {
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
Expand All @@ -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 || '{}';
Expand Down
Loading