diff --git a/README.md b/README.md index 2773f9ef..93092b3c 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, + method: { + '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.md b/docs/api/method.md new file mode 100644 index 00000000..4452f83e --- /dev/null +++ b/docs/api/method.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, + method: { + '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 4fc8a670..de69f804 100644 --- a/src/express.ts +++ b/src/express.ts @@ -4,13 +4,14 @@ 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'); @@ -149,14 +150,24 @@ 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 = produceMethod({ + typeName: queryType.name, + fieldName, + methodMap: sofa.method, + defaultValue: 'GET', + }); + + 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: 'GET', + method: method.toUpperCase() as Method, }; } @@ -171,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, @@ -181,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.method, + 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, }; } @@ -284,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 8a9abbce..fa52d0a1 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: ['POST', 'PUT', 'PATCH'].includes(info.method), }); }, 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/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/src/sofa.ts b/src/sofa.ts index 0775cbb8..18cb5f5d 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"} + */ + method?: MethodMap; } export interface Sofa { @@ -36,6 +52,7 @@ export interface Sofa { context: Context; models: string[]; ignore: Ignore; + method?: 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/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 diff --git a/tests/router.spec.ts b/tests/router.spec.ts index a113ceec..9296bacf 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'; @@ -55,13 +56,17 @@ 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', name: 'Foo', }, ]; + + const spy = jest.fn(() => users); + const spyMutation = jest.fn(() => users[0]); + const sofa = createSofa({ schema: makeExecutableSchema({ typeDefs: /* GraphQL */ ` @@ -69,37 +74,68 @@ test('should parse InputTypeObject', done => { offset: Int limit: Int! } - + type User { id: ID name: String } - + type Query { - usersInfo(pageInfo: PageInfoInput!): [User] + users(pageInfo: PageInfoInput!): [User] + } + + type Mutation { + addRandomUser: User } `, resolvers: { Query: { - usersInfo: () => users, + users: spy, + }, + Mutation: { + addRandomUser: spyMutation, }, }, }), + method: { + 'Query.users': 'POST', + 'Mutation.addRandomUser': 'GET', + }, }); 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); - done(); + expect(spy.mock.calls[0][1]).toEqual(params); + + 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(); + } + }); } }); }); 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" ] } }