From c0654c509382f1b5bc7866355847cd668f484146 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hajnal=20Benj=C3=A1min?= Date: Wed, 29 May 2019 22:18:25 +0200 Subject: [PATCH 1/5] feat: using POST for queries with input typed arguments --- src/express.ts | 24 +++++++++++++++++++++--- src/parse.ts | 2 +- tests/router.spec.ts | 17 ++++++++++++++--- 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/express.ts b/src/express.ts index 4fc8a670..5fb95f62 100644 --- a/src/express.ts +++ b/src/express.ts @@ -1,5 +1,12 @@ import * as express from 'express'; -import { DocumentNode, print, isObjectType, isNonNullType } from 'graphql'; +import { + DocumentNode, + print, + isObjectType, + isNonNullType, + GraphQLInputObjectType, + GraphQLNonNull, +} from 'graphql'; import { buildOperation } from './operation'; import { getOperationInfo, OperationInfo } from './ast'; @@ -149,14 +156,16 @@ function createQueryRoute({ const hasIdArgument = field.args.some(arg => arg.name === 'id'); const path = getPath(fieldName, isSingle && hasIdArgument); - router.get(path, useHandler({ info, fieldName, sofa, operation })); + const method = field.args.find(arg => isInputType(arg.type)) ? 'post' : 'get'; + + router[method](path, useHandler({ info, fieldName, sofa, operation })); logger.debug(`[Router] ${fieldName} query available at ${path}`); return { document: operation, path, - method: 'GET', + method: method.toUpperCase() as 'POST' | 'GET', }; } @@ -266,6 +275,15 @@ function pickParam(req: express.Request, name: string) { } } +// Graphql provided isInputType accepts GraphQLScalarType, GraphQLEnumType. +function isInputType(type: any): boolean { + if (type instanceof GraphQLNonNull) { + return isInputType(type.ofType); + } + + return type instanceof GraphQLInputObjectType; +} + function useAsync( handler: ( req: express.Request, diff --git a/src/parse.ts b/src/parse.ts index 5e505b29..9dbfb47e 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -51,7 +51,7 @@ function resolveVariable({ } if (isInputObjectType(namedType)) { - return value && JSON.parse(value); + return value && typeof value === 'object' ? value : JSON.parse(value); } return value; diff --git a/tests/router.spec.ts b/tests/router.spec.ts index a113ceec..2de63072 100644 --- a/tests/router.spec.ts +++ b/tests/router.spec.ts @@ -1,6 +1,7 @@ import { makeExecutableSchema } from 'graphql-tools'; import * as supertest from 'supertest'; import * as express from 'express'; +import * as bodyParser from 'body-parser'; import { schema, models } from './schema'; import { createRouter } from '../src/express'; import { createSofa } from '../src'; @@ -62,6 +63,7 @@ test('should parse InputTypeObject', done => { name: 'Foo', }, ]; + const spy = jest.fn(() => users); const sofa = createSofa({ schema: makeExecutableSchema({ typeDefs: /* GraphQL */ ` @@ -69,12 +71,12 @@ test('should parse InputTypeObject', done => { offset: Int limit: Int! } - + type User { id: ID name: String } - + type Query { usersInfo(pageInfo: PageInfoInput!): [User] } @@ -90,15 +92,24 @@ test('should parse InputTypeObject', done => { const router = createRouter(sofa); const app = express(); + app.use(bodyParser.json()); app.use('/api', router); + const params = { + pageInfo: { + limit: 5, + }, + }; + supertest(app) - .get('/api/users-info?pageInfo={"limit": 5}') + .post('/api/users') + .send(params) .expect(200, (err, res) => { if (err) { done.fail(err); } else { expect(res.body).toEqual(users); + expect(spy.mock.calls[0][1]).toEqual(params); done(); } }); From 0bb12a2b364a2f583bd532f735e4f0db898261b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hajnal=20Benj=C3=A1min?= Date: Thu, 30 May 2019 00:25:15 +0200 Subject: [PATCH 2/5] impr: OpenAPI generates requestBody for every POST request --- src/open-api/index.ts | 1 + src/open-api/operations.ts | 7 ++++--- tests/open-api/operations.spec.ts | 2 ++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/open-api/index.ts b/src/open-api/index.ts index 8a9abbce..c3bf9e07 100644 --- a/src/open-api/index.ts +++ b/src/open-api/index.ts @@ -63,6 +63,7 @@ export function OpenAPI({ url: path, operation: info.document, schema, + useRequestBody: info.method === 'POST', }); }, get() { diff --git a/src/open-api/operations.ts b/src/open-api/operations.ts index 427c038f..e9c8759c 100644 --- a/src/open-api/operations.ts +++ b/src/open-api/operations.ts @@ -14,20 +14,21 @@ export function buildPathFromOperation({ url, schema, operation, + useRequestBody, }: { url: string; schema: GraphQLSchema; operation: DocumentNode; + useRequestBody: boolean; }) { const info = getOperationInfo(operation)!; - const isQuery = info.operation.operation === 'query'; return { operationId: info.name, - parameters: isQuery + parameters: !useRequestBody ? resolveParameters(url, info.operation.variableDefinitions) : [], - requestBody: !isQuery + requestBody: useRequestBody ? { content: { 'application/json': { diff --git a/tests/open-api/operations.spec.ts b/tests/open-api/operations.spec.ts index f19e8ca0..49ea0b4c 100644 --- a/tests/open-api/operations.spec.ts +++ b/tests/open-api/operations.spec.ts @@ -37,6 +37,7 @@ test('handle query', async () => { url: '/api/feed', operation, schema, + useRequestBody: false, }); expect(result.operationId).toEqual('feedQuery'); @@ -70,6 +71,7 @@ test('handle mutation', async () => { url: '/api/add-post', operation, schema, + useRequestBody: true, }); // id From 1f48294cbcc6684baf30e3142070ead4795e74c7 Mon Sep 17 00:00:00 2001 From: Kamil Kisiela Date: Wed, 17 Jul 2019 16:07:18 +0200 Subject: [PATCH 3/5] feat: Customize endpoint's HTTP Method --- README.md | 20 ++++++++++++ docs/api/ignore.md | 2 +- docs/api/method-map.md | 21 ++++++++++++ src/express.ts | 74 ++++++++++++++++++++++++++++-------------- src/open-api/index.ts | 2 +- src/sofa.ts | 21 ++++++++++-- src/types.ts | 6 +++- tests/router.spec.ts | 27 +++++++++++++-- 8 files changed, 141 insertions(+), 32 deletions(-) create mode 100644 docs/api/method-map.md diff --git a/README.md b/README.md index 2773f9ef..3ea6eba9 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,26 @@ Whenever Sofa tries to resolve an author of a message, instead of exposing an ID > Pattern is easy: `Type:field` or `Type` +### Customize endpoint's HTTP Method + +Sofa allows you to cutomize the http method. For example, in case you need `POST` instead of `GET` method in one of your query, you do the following: + +```typescript +api.use( + '/api', + sofa({ + schema, + methodMap: { + 'Query.feed': 'POST', + }, + }) +); +``` + +When Sofa tries to define a route for `feed` of `Query`, instead of exposing it under `GET` (default for Query type) it will use `POST` method. + +> Pattern is easy: `Type.field` where `Type` is your query or mutation type. + ### Custom depth limit Sofa prevents circular references by default, but only one level deep. In order to change it, set the `depthLimit` option to any number: diff --git a/docs/api/ignore.md b/docs/api/ignore.md index 583b495c..ca3f27f0 100644 --- a/docs/api/ignore.md +++ b/docs/api/ignore.md @@ -16,4 +16,4 @@ api.use( Whenever Sofa tries to resolve an author of a message, instead of exposing an ID it will pass whole data. -> Pattern is easy: `Type:field` or `Type` +> Pattern is easy: `Type.field` or `Type` diff --git a/docs/api/method-map.md b/docs/api/method-map.md new file mode 100644 index 00000000..856ab7a1 --- /dev/null +++ b/docs/api/method-map.md @@ -0,0 +1,21 @@ +--- +title: Customize endpoint's HTTP Method +--- + +Sofa allows you to cutomize the http method. For example, in case you need `POST` instead of `GET` method in one of your query, you do the following: + +```typescript +api.use( + '/api', + sofa({ + schema, + methodMap: { + 'Query.feed': 'POST', + }, + }) +); +``` + +When Sofa tries to define a route for `feed` of `Query`, instead of exposing it under `GET` (default for Query type) it will use `POST` method. + +> Pattern is easy: `Type.field` where `Type` is your query or mutation type. diff --git a/src/express.ts b/src/express.ts index 5fb95f62..57d5dd67 100644 --- a/src/express.ts +++ b/src/express.ts @@ -1,23 +1,17 @@ import * as express from 'express'; -import { - DocumentNode, - print, - isObjectType, - isNonNullType, - GraphQLInputObjectType, - GraphQLNonNull, -} from 'graphql'; +import { DocumentNode, print, isObjectType, isNonNullType } from 'graphql'; import { buildOperation } from './operation'; import { getOperationInfo, OperationInfo } from './ast'; import { Sofa, isContextFn } from './sofa'; -import { RouteInfo } from './types'; +import { RouteInfo, Method, MethodMap } from './types'; import { convertName } from './common'; import { parseVariable } from './parse'; import { StartSubscriptionEvent, SubscriptionManager } from './subscriptions'; import { logger } from './logger'; export type ErrorHandler = (res: express.Response, error: any) => void; +export type ExpressMethod = 'get' | 'post' | 'put' | 'delete' | 'patch'; export function createRouter(sofa: Sofa): express.Router { logger.debug('[Sofa] Creating router'); @@ -156,16 +150,24 @@ function createQueryRoute({ const hasIdArgument = field.args.some(arg => arg.name === 'id'); const path = getPath(fieldName, isSingle && hasIdArgument); - const method = field.args.find(arg => isInputType(arg.type)) ? 'post' : 'get'; + const method = produceMethod({ + typeName: queryType.name, + fieldName, + methodMap: sofa.methodMap, + defaultValue: 'GET', + }); - router[method](path, useHandler({ info, fieldName, sofa, operation })); + router[method.toLocaleLowerCase() as ExpressMethod]( + path, + useHandler({ info, fieldName, sofa, operation }) + ); - logger.debug(`[Router] ${fieldName} query available at ${path}`); + logger.debug(`[Router] ${fieldName} query available at ${method} ${path}`); return { document: operation, path, - method: method.toUpperCase() as 'POST' | 'GET', + method: method.toUpperCase() as Method, }; } @@ -180,6 +182,7 @@ function createMutationRoute({ }): RouteInfo { logger.debug(`[Router] Creating ${fieldName} mutation`); + const mutationType = sofa.schema.getMutationType()!; const operation = buildOperation({ kind: 'mutation', schema: sofa.schema, @@ -190,14 +193,24 @@ function createMutationRoute({ const info = getOperationInfo(operation)!; const path = getPath(fieldName); - router.post(path, useHandler({ info, fieldName, sofa, operation })); + const method = produceMethod({ + typeName: mutationType.name, + fieldName, + methodMap: sofa.methodMap, + defaultValue: 'POST', + }); + + router[method.toLowerCase() as ExpressMethod]( + path, + useHandler({ info, fieldName, sofa, operation }) + ); - logger.debug(`[Router] ${fieldName} mutation available at ${path}`); + logger.debug(`[Router] ${fieldName} mutation available at ${method} ${path}`); return { document: operation, path, - method: 'POST', + method: method.toUpperCase() as Method, }; } @@ -275,15 +288,6 @@ function pickParam(req: express.Request, name: string) { } } -// Graphql provided isInputType accepts GraphQLScalarType, GraphQLEnumType. -function isInputType(type: any): boolean { - if (type instanceof GraphQLNonNull) { - return isInputType(type.ofType); - } - - return type instanceof GraphQLInputObjectType; -} - function useAsync( handler: ( req: express.Request, @@ -302,3 +306,23 @@ function useAsync( }); }; } + +function produceMethod({ + typeName, + fieldName, + methodMap, + defaultValue, +}: { + typeName: string; + fieldName: string; + methodMap?: MethodMap; + defaultValue: Method; +}): Method { + const path = `${typeName}.${fieldName}`; + + if (methodMap && methodMap[path]) { + return methodMap[path]; + } + + return defaultValue; +} diff --git a/src/open-api/index.ts b/src/open-api/index.ts index c3bf9e07..fa52d0a1 100644 --- a/src/open-api/index.ts +++ b/src/open-api/index.ts @@ -63,7 +63,7 @@ export function OpenAPI({ url: path, operation: info.document, schema, - useRequestBody: info.method === 'POST', + useRequestBody: ['POST', 'PUT', 'PATCH'].includes(info.method), }); }, get() { diff --git a/src/sofa.ts b/src/sofa.ts index 0775cbb8..40cf575f 100644 --- a/src/sofa.ts +++ b/src/sofa.ts @@ -10,7 +10,14 @@ import { GraphQLOutputType, } from 'graphql'; -import { Ignore, Context, ContextFn, ExecuteFn, OnRoute } from './types'; +import { + Ignore, + Context, + ContextFn, + ExecuteFn, + OnRoute, + MethodMap, +} from './types'; import { convertName } from './common'; import { logger } from './logger'; import { ErrorHandler } from './express'; @@ -25,10 +32,19 @@ export interface SofaConfig { schema: GraphQLSchema; context?: Context; execute?: ExecuteFn; - ignore?: Ignore; // treat an Object with an ID as not a model - accepts ['User', 'Message.author'] + /** + * Treats an Object with an ID as not a model. + * @example ["User", "Message.author"] + */ + ignore?: Ignore; onRoute?: OnRoute; depthLimit?: number; errorHandler?: ErrorHandler; + /** + * Overwrites the default HTTP method. + * @example {"Query.field": "GET", "Mutation.field": "POST"} + */ + methodMap?: MethodMap; } export interface Sofa { @@ -36,6 +52,7 @@ export interface Sofa { context: Context; models: string[]; ignore: Ignore; + methodMap?: MethodMap; execute: ExecuteFn; onRoute?: OnRoute; errorHandler?: ErrorHandler; diff --git a/src/types.ts b/src/types.ts index 1ff5237c..9467fc5c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,9 +8,13 @@ export type Ignore = string[]; export type ExecuteFn = (args: GraphQLArgs) => Promise>; +export type Method = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + export interface RouteInfo { document: DocumentNode; path: string; - method: 'GET' | 'POST'; + method: Method; } export type OnRoute = (info: RouteInfo) => void; + +export type MethodMap = Record; diff --git a/tests/router.spec.ts b/tests/router.spec.ts index 2de63072..4114f07a 100644 --- a/tests/router.spec.ts +++ b/tests/router.spec.ts @@ -56,7 +56,7 @@ test('should work with Mutation', async () => { expect(route.methods.post).toEqual(true); }); -test('should parse InputTypeObject', done => { +test('should overwrite a default http method on demand', done => { const users = [ { id: 'user:foo', @@ -64,6 +64,7 @@ test('should parse InputTypeObject', done => { }, ]; const spy = jest.fn(() => users); + const spyMutation = jest.fn(() => users[0]); const sofa = createSofa({ schema: makeExecutableSchema({ typeDefs: /* GraphQL */ ` @@ -80,13 +81,24 @@ test('should parse InputTypeObject', done => { type Query { usersInfo(pageInfo: PageInfoInput!): [User] } + + type Mutation { + addRandomUser: User + } `, resolvers: { Query: { usersInfo: () => users, }, + Mutation: { + addRandomUser: spyMutation, + }, }, }), + methodMap: { + 'Query.users': 'POST', + 'Mutation.addRandomUser': 'GET', + }, }); const router = createRouter(sofa); @@ -110,7 +122,18 @@ test('should parse InputTypeObject', done => { } else { expect(res.body).toEqual(users); expect(spy.mock.calls[0][1]).toEqual(params); - done(); + + supertest(app) + .get('/api/add-random-user') + .send() + .expect(200, (err, res) => { + if (err) { + done.fail(err); + } else { + expect(res.body).toEqual(users[0]); + done(); + } + }); } }); }); From 5ea7f83b4f64e526d9f5aceafa74bbb62ba47ad4 Mon Sep 17 00:00:00 2001 From: Kamil Kisiela Date: Wed, 17 Jul 2019 16:18:14 +0200 Subject: [PATCH 4/5] Fix test --- tests/router.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/router.spec.ts b/tests/router.spec.ts index 4114f07a..c3019cc5 100644 --- a/tests/router.spec.ts +++ b/tests/router.spec.ts @@ -63,8 +63,10 @@ test('should overwrite a default http method on demand', done => { name: 'Foo', }, ]; + const spy = jest.fn(() => users); const spyMutation = jest.fn(() => users[0]); + const sofa = createSofa({ schema: makeExecutableSchema({ typeDefs: /* GraphQL */ ` @@ -79,7 +81,7 @@ test('should overwrite a default http method on demand', done => { } type Query { - usersInfo(pageInfo: PageInfoInput!): [User] + users(pageInfo: PageInfoInput!): [User] } type Mutation { @@ -88,7 +90,7 @@ test('should overwrite a default http method on demand', done => { `, resolvers: { Query: { - usersInfo: () => users, + users: spy, }, Mutation: { addRandomUser: spyMutation, From 769ff064c4468d5339dd18409aabc6b4d10d845e Mon Sep 17 00:00:00 2001 From: Kamil Kisiela Date: Wed, 17 Jul 2019 16:25:48 +0200 Subject: [PATCH 5/5] Rename to just `method` --- README.md | 2 +- docs/api/{method-map.md => method.md} | 2 +- src/express.ts | 4 ++-- src/sofa.ts | 4 ++-- tests/router.spec.ts | 2 +- website/sidebars.json | 3 ++- 6 files changed, 9 insertions(+), 8 deletions(-) rename docs/api/{method-map.md => method.md} (96%) diff --git a/README.md b/README.md index 3ea6eba9..93092b3c 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ api.use( '/api', sofa({ schema, - methodMap: { + method: { 'Query.feed': 'POST', }, }) diff --git a/docs/api/method-map.md b/docs/api/method.md similarity index 96% rename from docs/api/method-map.md rename to docs/api/method.md index 856ab7a1..4452f83e 100644 --- a/docs/api/method-map.md +++ b/docs/api/method.md @@ -9,7 +9,7 @@ api.use( '/api', sofa({ schema, - methodMap: { + method: { 'Query.feed': 'POST', }, }) diff --git a/src/express.ts b/src/express.ts index 57d5dd67..de69f804 100644 --- a/src/express.ts +++ b/src/express.ts @@ -153,7 +153,7 @@ function createQueryRoute({ const method = produceMethod({ typeName: queryType.name, fieldName, - methodMap: sofa.methodMap, + methodMap: sofa.method, defaultValue: 'GET', }); @@ -196,7 +196,7 @@ function createMutationRoute({ const method = produceMethod({ typeName: mutationType.name, fieldName, - methodMap: sofa.methodMap, + methodMap: sofa.method, defaultValue: 'POST', }); diff --git a/src/sofa.ts b/src/sofa.ts index 40cf575f..18cb5f5d 100644 --- a/src/sofa.ts +++ b/src/sofa.ts @@ -44,7 +44,7 @@ export interface SofaConfig { * Overwrites the default HTTP method. * @example {"Query.field": "GET", "Mutation.field": "POST"} */ - methodMap?: MethodMap; + method?: MethodMap; } export interface Sofa { @@ -52,7 +52,7 @@ export interface Sofa { context: Context; models: string[]; ignore: Ignore; - methodMap?: MethodMap; + method?: MethodMap; execute: ExecuteFn; onRoute?: OnRoute; errorHandler?: ErrorHandler; diff --git a/tests/router.spec.ts b/tests/router.spec.ts index c3019cc5..9296bacf 100644 --- a/tests/router.spec.ts +++ b/tests/router.spec.ts @@ -97,7 +97,7 @@ test('should overwrite a default http method on demand', done => { }, }, }), - methodMap: { + method: { 'Query.users': 'POST', 'Mutation.addRandomUser': 'GET', }, diff --git a/website/sidebars.json b/website/sidebars.json index 3dbc1de5..5f30aa80 100644 --- a/website/sidebars.json +++ b/website/sidebars.json @@ -12,7 +12,8 @@ "api/context", "api/ignore", "api/execute", - "api/error-handler" + "api/error-handler", + "api/method" ] } }