From 1f48294cbcc6684baf30e3142070ead4795e74c7 Mon Sep 17 00:00:00 2001 From: Kamil Kisiela Date: Wed, 17 Jul 2019 16:07:18 +0200 Subject: [PATCH] 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(); + } + }); } }); });