From 26aa937c114fb8596dfefc599b1f53cead69c159 Mon Sep 17 00:00:00 2001 From: Dmitrii Maganov Date: Sun, 9 Jan 2022 05:21:16 +0300 Subject: [PATCH] feat(express, koa): make transports similar (#2486) --- .../authentication/src/hooks/authenticate.ts | 2 +- packages/client/test/fixture.ts | 11 +- packages/express/package.json | 5 +- packages/express/src/authentication.ts | 82 ++++----- packages/express/src/declarations.ts | 28 +-- packages/express/src/handlers.ts | 8 +- packages/express/src/index.ts | 65 +++---- packages/express/src/rest.ts | 166 +++++++++--------- packages/express/test/authentication.test.ts | 6 +- packages/express/test/index.test.ts | 2 +- packages/express/test/rest.test.ts | 19 +- packages/feathers/src/application.ts | 2 +- packages/koa/package.json | 3 + packages/koa/src/authenticate.ts | 40 ----- packages/koa/src/authentication.ts | 56 ++++++ packages/koa/src/declarations.ts | 17 +- .../koa/src/{error-handler.ts => handlers.ts} | 2 +- packages/koa/src/index.ts | 47 +++-- packages/koa/src/rest.ts | 129 +++++++++----- packages/koa/test/app.fixture.ts | 5 +- packages/koa/test/index.test.ts | 20 ++- packages/rest-client/test/server.ts | 18 +- packages/tests/package.json | 2 +- .../transport-commons/src/channels/mixins.ts | 2 +- 24 files changed, 401 insertions(+), 336 deletions(-) delete mode 100644 packages/koa/src/authenticate.ts create mode 100644 packages/koa/src/authentication.ts rename packages/koa/src/{error-handler.ts => handlers.ts} (98%) diff --git a/packages/authentication/src/hooks/authenticate.ts b/packages/authentication/src/hooks/authenticate.ts index b9e47d8f8b..5c1103a056 100644 --- a/packages/authentication/src/hooks/authenticate.ts +++ b/packages/authentication/src/hooks/authenticate.ts @@ -8,7 +8,7 @@ const debug = createDebug('@feathersjs/authentication/hooks/authenticate'); export interface AuthenticateHookSettings { service?: string; - strategies: string[]; + strategies?: string[]; } export default (originalSettings: string | AuthenticateHookSettings, ...originalStrategies: string[]) => { diff --git a/packages/client/test/fixture.ts b/packages/client/test/fixture.ts index cfd29b7367..c286bc1a8f 100644 --- a/packages/client/test/fixture.ts +++ b/packages/client/test/fixture.ts @@ -31,17 +31,18 @@ class TodoService extends Service { export default (configurer?: (app: Application) => void) => { const app = express.default(feathers()) + // Parse HTTP bodies + .use(express.json()) + .use(express.urlencoded({ extended: true })) + // Host the current directory (for index.html) + .use(express.static(process.cwd())) .configure(express.rest()); if (typeof configurer === 'function') { configurer.call(app, app); } - // Parse HTTP bodies - app.use(express.json()) - .use(express.urlencoded({ extended: true })) - // Host the current directory (for index.html) - .use(express.static(process.cwd())) + app // Host our Todos service on the /todos path .use('/todos', new TodoService({ multi: true diff --git a/packages/express/package.json b/packages/express/package.json index fdecb2ffc7..1580351fbc 100644 --- a/packages/express/package.json +++ b/packages/express/package.json @@ -49,17 +49,16 @@ "access": "public" }, "dependencies": { + "@feathersjs/authentication": "^5.0.0-pre.15", "@feathersjs/commons": "^5.0.0-pre.15", "@feathersjs/errors": "^5.0.0-pre.15", "@feathersjs/feathers": "^5.0.0-pre.15", "@feathersjs/transport-commons": "^5.0.0-pre.15", "@types/express": "^4.17.13", "@types/express-serve-static-core": "^4.17.27", - "express": "^4.17.2", - "lodash": "^4.17.21" + "express": "^4.17.2" }, "devDependencies": { - "@feathersjs/authentication": "^5.0.0-pre.15", "@feathersjs/authentication-local": "^5.0.0-pre.15", "@feathersjs/tests": "^5.0.0-pre.15", "@types/lodash": "^4.14.178", diff --git a/packages/express/src/authentication.ts b/packages/express/src/authentication.ts index 366b05005d..4172c80c67 100644 --- a/packages/express/src/authentication.ts +++ b/packages/express/src/authentication.ts @@ -1,71 +1,61 @@ +import { RequestHandler, Request, Response } from 'express'; +import { HookContext } from '@feathersjs/feathers'; import { createDebug } from '@feathersjs/commons'; -import { merge, flatten } from 'lodash'; -import { NextFunction, RequestHandler, Request, Response } from 'express'; +import { authenticate as AuthenticateHook } from '@feathersjs/authentication'; + +import { Application } from './declarations'; const debug = createDebug('@feathersjs/express/authentication'); -type StrategyOptions = { - service?: string; - strategies: string[] +const toHandler = (func: (req: Request, res: Response, next: () => void) => Promise): RequestHandler => { + return (req, res, next) => func(req, res, next).catch(error => next(error)); }; -const normalizeStrategy = (_settings: string|StrategyOptions, ..._strategies: string[]) => - typeof _settings === 'string' - ? { strategies: flatten([ _settings, ..._strategies ]) } - : _settings; +export type AuthenticationSettings = { + service?: string; + strategies?: string[]; +}; -export function parseAuthentication (settings: any = {}): RequestHandler { - return function (req, res, next) { - const app = req.app as any; - const service = app.defaultAuthentication ? app.defaultAuthentication(settings.service) : null; +export function parseAuthentication (settings: AuthenticationSettings = {}): RequestHandler { + return toHandler(async (req, res, next) => { + const app = req.app as any as Application; + const service = app.defaultAuthentication?.(settings.service); - if (service === null) { + if (!service) { return next(); } const config = service.configuration; - const authStrategies = config.parseStrategies || config.authStrategies || []; + const authStrategies = settings.strategies || config.parseStrategies || config.authStrategies || []; if (authStrategies.length === 0) { debug('No `authStrategies` or `parseStrategies` found in authentication configuration'); return next(); } - service.parse(req, res, ...authStrategies) - .then((authentication: any) => { - if (authentication) { - debug('Parsed authentication from HTTP header', authentication); - merge(req, { - authentication, - feathers: { authentication } - }); - } - - next(); - }).catch(next); - }; -} + const authentication = await service.parse(req, res, ...authStrategies) + + if (authentication) { + debug('Parsed authentication from HTTP header', authentication); + req.feathers = { ...req.feathers, authentication }; + } -export function authenticate (_settings: string|StrategyOptions, ..._strategies: string[]) { - const settings = normalizeStrategy(_settings, ..._strategies); + return next(); + }); +} - if (!Array.isArray(settings.strategies) || settings.strategies.length === 0) { - throw new Error('\'authenticate\' middleware requires at least one strategy name'); - } +export function authenticate (settings: string | AuthenticationSettings, ...strategies: string[]): RequestHandler { + const hook = AuthenticateHook(settings, ...strategies); - return (_req: Request, _res: Response, next: NextFunction) => { - const req = _req as any; - const { app, authentication } = req; - const service = app.defaultAuthentication(settings.service); + return toHandler(async (req, _res, next) => { + const app = req.app as any as Application; + const params = req.feathers; + const context = { app, params } as any as HookContext; - debug('Authenticating with Express middleware and strategies', settings.strategies); + await hook(context); - service.authenticate(authentication, req.feathers, ...settings.strategies) - .then((authResult: any) => { - debug('Merging request with', authResult); - merge(req, authResult); + req.feathers = context.params; - next(); - }).catch(next); - }; + return next(); + }); } diff --git a/packages/express/src/declarations.ts b/packages/express/src/declarations.ts index 2b1056170b..6418587b25 100644 --- a/packages/express/src/declarations.ts +++ b/packages/express/src/declarations.ts @@ -2,7 +2,7 @@ import http from 'http'; import express, { Express } from 'express'; import { Application as FeathersApplication, Params as FeathersParams, - HookContext, ServiceMethods, ServiceInterface + HookContext, ServiceMethods, ServiceInterface, RouteLookup } from '@feathersjs/feathers'; interface ExpressUseHandler { @@ -33,28 +33,30 @@ export type Application = declare module '@feathersjs/feathers/lib/declarations' { interface ServiceOptions { - middleware?: { - before: express.RequestHandler[], - after: express.RequestHandler[] - } + express?: { + before?: express.RequestHandler[]; + after?: express.RequestHandler[]; + composed?: express.RequestHandler; + }; } } declare module 'express-serve-static-core' { interface Request { - feathers?: Partial; + feathers?: Partial; + lookup?: RouteLookup; } interface Response { - data?: any; - hook?: HookContext; + data?: any; + hook?: HookContext; } interface IRouterMatcher { - // eslint-disable-next-line -

( - path: PathParams, - ...handlers: (RequestHandler | Partial | Application)[] - ): T; + // eslint-disable-next-line +

( + path: PathParams, + ...handlers: (RequestHandler | Partial | Application)[] + ): T; } } diff --git a/packages/express/src/handlers.ts b/packages/express/src/handlers.ts index 9ad8f0f471..364d8d4192 100644 --- a/packages/express/src/handlers.ts +++ b/packages/express/src/handlers.ts @@ -18,10 +18,10 @@ export function notFound ({ verbose = false } = {}): RequestHandler { } export type ErrorHandlerOptions = { - public?: string, - logger?: boolean|{ error?: (msg: any) => void, info?: (msg: any) => void }, - html?: any, - json?: any + public?: string; + logger?: boolean|{ error?: (msg: any) => void, info?: (msg: any) => void }; + html?: any; + json?: any; }; export function errorHandler (_options: ErrorHandlerOptions = {}): ErrorRequestHandler { diff --git a/packages/express/src/index.ts b/packages/express/src/index.ts index d75df86052..5a0249e446 100644 --- a/packages/express/src/index.ts +++ b/packages/express/src/index.ts @@ -1,23 +1,16 @@ -import express, { - Express, static as _static, json, raw, text, urlencoded, query -} from 'express'; -import { - Application as FeathersApplication, defaultServiceMethods -} from '@feathersjs/feathers'; +import express, { Express } from 'express'; +import { Application as FeathersApplication, defaultServiceMethods } from '@feathersjs/feathers'; +import { routing } from '@feathersjs/transport-commons'; import { createDebug } from '@feathersjs/commons'; import { Application } from './declarations'; -import { errorHandler, notFound } from './handlers'; -import { parseAuthentication, authenticate } from './authentication'; -export { - _static as serveStatic, _static as static, json, raw, text, - urlencoded, query, errorHandler, notFound, express as original, - authenticate, parseAuthentication -}; +export { default as original, static, static as serveStatic, json, raw, text, urlencoded, query } from 'express'; -export * from './rest'; +export * from './authentication'; export * from './declarations'; +export * from './handlers'; +export * from './rest'; const debug = createDebug('@feathersjs/express'); @@ -30,10 +23,12 @@ export default function feathersExpress (feathersApp?: Feather throw new Error('@feathersjs/express requires a valid Feathers application instance'); } - const { use, listen } = expressApp as any; - // A mixin that provides the extended functionality - const mixin: any = { - use (location: string, ...rest: any[]) { + const app = expressApp as any as Application; + const { use: expressUse, listen: expressListen } = expressApp as any; + const feathersUse = feathersApp.use; + + Object.assign(app, { + use (location: string & keyof S, ...rest: any[]) { let service: any; let options = {}; @@ -60,46 +55,56 @@ export default function feathersExpress (feathersApp?: Feather // Check for service (any object with at least one service method) if (hasMethod(['handle', 'set']) || !hasMethod(defaultServiceMethods)) { debug('Passing app.use call to Express app'); - return use.call(this, location, ...rest); + return expressUse.call(this, location, ...rest); } debug('Registering service with middleware', middleware); // Since this is a service, call Feathers `.use` - (feathersApp as FeathersApplication).use.call(this, location, service, { + feathersUse.call(this, location, service, { ...options, - middleware + express: middleware }); return this; }, async listen (...args: any[]) { - const server = listen.call(this, ...args); + const server = expressListen.call(this, ...args); await this.setup(server); debug('Feathers application listening'); return server; } - }; + } as Application); - const feathersDescriptors = { + const appDescriptors = { + ...Object.getOwnPropertyDescriptors(Object.getPrototypeOf(app)), + ...Object.getOwnPropertyDescriptors(app) + }; + const newDescriptors = { ...Object.getOwnPropertyDescriptors(Object.getPrototypeOf(feathersApp)), ...Object.getOwnPropertyDescriptors(feathersApp) }; // Copy all non-existing properties (including non-enumerables) // that don't already exist on the Express app - Object.keys(feathersDescriptors).forEach(prop => { - const feathersProp = feathersDescriptors[prop]; - const expressProp = Object.getOwnPropertyDescriptor(expressApp, prop); + Object.keys(newDescriptors).forEach(prop => { + const appProp = appDescriptors[prop]; + const newProp = newDescriptors[prop]; - if (expressProp === undefined && feathersProp !== undefined) { - Object.defineProperty(expressApp, prop, feathersProp); + if (appProp === undefined && newProp !== undefined) { + Object.defineProperty(expressApp, prop, newProp); } }); - return Object.assign(expressApp, mixin); + app.configure(routing() as any); + app.use((req, _res, next) => { + req.feathers = { ...req.feathers, provider: 'rest' }; + return next(); + }); + + return app; } if (typeof module !== 'undefined') { diff --git a/packages/express/src/rest.ts b/packages/express/src/rest.ts index 132be382dc..35e280e484 100644 --- a/packages/express/src/rest.ts +++ b/packages/express/src/rest.ts @@ -1,119 +1,111 @@ +import { Request, Response, RequestHandler, Router } from 'express'; import { MethodNotAllowed } from '@feathersjs/errors'; import { createDebug } from '@feathersjs/commons'; import { http } from '@feathersjs/transport-commons'; -import { HookContext, createContext, defaultServiceMethods, getServiceOptions } from '@feathersjs/feathers'; -import { Request, Response, NextFunction, RequestHandler, Router } from 'express'; +import { createContext, defaultServiceMethods, getServiceOptions } from '@feathersjs/feathers'; -import { parseAuthentication } from './authentication'; +import { AuthenticationSettings, parseAuthentication } from './authentication'; +import { Application } from './declarations'; const debug = createDebug('@feathersjs/express/rest'); -export type ServiceCallback = (req: Request, res: Response, options: http.ServiceParams) => Promise; +const toHandler = (func: (req: Request, res: Response, next: () => void) => Promise): RequestHandler => { + return (req, res, next) => func(req, res, next).catch(error => next(error)); +}; -export const feathersParams = (req: Request, _res: Response, next: NextFunction) => { - req.feathers = { - ...req.feathers, - provider: 'rest', - headers: req.headers - }; - next(); -} +const serviceMiddleware = (): RequestHandler => { + return toHandler(async (req, res, next) => { + const { query, headers, path, body: data, method: httpMethod } = req; + const methodOverride = req.headers[http.METHOD_HEADER] as string | undefined; -export const formatter = (_req: Request, res: Response, next: NextFunction) => { - if (res.data === undefined) { - return next(); - } + const { service, params: { __id: id = null, ...route } = {} } = req.lookup!; + const method = http.getServiceMethod(httpMethod, id, methodOverride); + const { methods } = getServiceOptions(service); - res.format({ - 'application/json' () { - res.json(res.data); + debug(`Found service for path ${path}, attempting to run '${method}' service method`); + + if (!methods.includes(method) || defaultServiceMethods.includes(methodOverride)) { + const error = new MethodNotAllowed(`Method \`${method}\` is not supported by this endpoint.`); + res.statusCode = error.code; + throw error; } - }); -} + const createArguments = http.argumentsFor[method as 'get'] || http.argumentsFor.default; + const params = { query, headers, route, ...req.feathers }; + const args = createArguments({ id, data, params }); + const contextBase = createContext(service, method, { http: {} }); + res.hook = contextBase; + + const context = await (service as any)[method](...args, contextBase); + res.hook = context; -export const serviceMiddleware = (callback: ServiceCallback) => - async (req: Request, res: Response, next: NextFunction) => { - debug(`Running service middleware for '${req.url}'`); + const result = http.getData(context); + const statusCode = http.getStatusCode(context, result); - try { - const { query, body: data } = req; - const { __feathersId: id = null, ...route } = req.params; - const params = { query, route, ...req.feathers }; - const context = await callback(req, res, { id, data, params }); - const result = http.getData(context); + res.data = result; + res.statusCode = statusCode; + + return next(); + }); +}; - res.data = result; - res.status(http.getStatusCode(context, result)); +const servicesMiddleware = (): RequestHandler => { + return toHandler(async (req, res, next) => { + const app = req.app as any as Application; + const lookup = app.lookup(req.path); - next(); - } catch (error: any) { - next(error); + if (!lookup) { + return next(); } - } -export const serviceMethodHandler = ( - service: any, methodName: string, getArgs: (opts: http.ServiceParams) => any[], headerOverride?: string -) => serviceMiddleware(async (req, res, options) => { - const methodOverride = typeof headerOverride === 'string' && (req.headers[headerOverride] as string); - const method = methodOverride ? methodOverride : methodName - const { methods } = getServiceOptions(service); + req.lookup = lookup; + + const options = getServiceOptions(lookup.service); + const middleware = options.express!.composed!; - if (!methods.includes(method) || defaultServiceMethods.includes(methodOverride)) { - res.status(http.statusCodes.methodNotAllowed); + return middleware(req, res, next); + }); +}; - throw new MethodNotAllowed(`Method \`${method}\` is not supported by this endpoint.`); +export const formatter: RequestHandler = (_req, res, next) => { + if (res.data === undefined) { + return next(); } - const args = getArgs(options); - const context = createContext(service, method, { http: {} }); + res.format({ + 'application/json' () { + res.json(res.data); + } + }); +}; + +export type RestOptions = { + formatter?: RequestHandler; + authentication?: AuthenticationSettings; +}; - res.hook = context; +export const rest = (options?: RestOptions | RequestHandler) => { + options = typeof options === 'function' ? { formatter: options } : options || {}; - return service[method](...args, context); -}); + const formatterMiddleware = options.formatter || formatter; + const authenticationOptions = options.authentication; -export function rest (handler: RequestHandler = formatter) { - return function (this: any, app: any) { + return (app: Application) => { if (typeof app.route !== 'function') { throw new Error('@feathersjs/express/rest needs an Express compatible app.'); } - app.use(feathersParams); - app.use(parseAuthentication()); - - // Register the REST provider - app.mixins.push(function (service: any, path: string, options: any) { - const { middleware: { before = [] } } = options; - let { middleware: { after = [] } } = options; - - if (typeof handler === 'function') { - after = after.concat(handler); - } - - const baseUri = `/${path}`; - const find = serviceMethodHandler(service, 'find', http.argumentsFor.find); - const get = serviceMethodHandler(service, 'get', http.argumentsFor.get); - const create = serviceMethodHandler(service, 'create', http.argumentsFor.create, http.METHOD_HEADER); - const update = serviceMethodHandler(service, 'update', http.argumentsFor.update); - const patch = serviceMethodHandler(service, 'patch', http.argumentsFor.patch); - const remove = serviceMethodHandler(service, 'remove', http.argumentsFor.remove); - - debug(`Adding REST provider for service \`${path}\` at base route \`${baseUri}\``); - - const idRoute = '/:__feathersId'; - const serviceRouter = Router({ mergeParams: true }) - .get('/', find) - .post('/', create) - .get(idRoute, get) - .put('/', update) - .put(idRoute, update) - .patch('/', patch) - .patch(idRoute, patch) - .delete('/', remove) - .delete(idRoute, remove); - - app.use(baseUri, ...before, serviceRouter, ...after); + app.use(parseAuthentication(authenticationOptions)); + app.use(servicesMiddleware()); + + app.mixins.push((_service, _path, options) => { + const { express: { before = [], after = [] } = {} } = options; + + const middlewares = [].concat(before, serviceMiddleware(), after, formatterMiddleware); + const middleware = Router().use(middlewares); + + options.express ||= {}; + options.express.composed = middleware; }); }; } diff --git a/packages/express/test/authentication.test.ts b/packages/express/test/authentication.test.ts index bc9a045067..020d54a2bb 100644 --- a/packages/express/test/authentication.test.ts +++ b/packages/express/test/authentication.test.ts @@ -36,7 +36,7 @@ describe('@feathersjs/express/authentication', () => { //@ts-ignore app.use('/protected', express.authenticate('jwt'), (req, res) => { - res.json(req.user); + res.json(req.feathers.user); }); app.use(express.errorHandler({ @@ -166,7 +166,7 @@ describe('@feathersjs/express/authentication', () => { const { data } = error.response; assert.strictEqual(data.name, 'NotAuthenticated'); - assert.strictEqual(data.message, 'Invalid authentication information (no `strategy` set)'); + assert.strictEqual(data.message, 'Not authenticated'); }); }); @@ -195,7 +195,7 @@ describe('@feathersjs/express/authentication', () => { assert.strictEqual(data.email, user.email); assert.strictEqual(data.id, user.id); - assert.strictEqual(data.password, undefined, 'Passed provider information'); + assert.strictEqual(data.password, user.password); }); }); }); diff --git a/packages/express/test/index.test.ts b/packages/express/test/index.test.ts index 89c3fb6127..715907936b 100644 --- a/packages/express/test/index.test.ts +++ b/packages/express/test/index.test.ts @@ -178,7 +178,7 @@ describe('@feathersjs/express', () => { feathersApp.use = function (path, serviceArg, options) { assert.strictEqual(path, '/myservice'); assert.strictEqual(serviceArg, service); - assert.deepStrictEqual(options.middleware, { + assert.deepStrictEqual(options.express, { before: [a, b], after: [c] }); diff --git a/packages/express/test/rest.test.ts b/packages/express/test/rest.test.ts index c1d6a2fb4f..69f5f2ced8 100644 --- a/packages/express/test/rest.test.ts +++ b/packages/express/test/rest.test.ts @@ -22,7 +22,7 @@ describe('@feathersjs/express/rest provider', () => { const app = feathers(); try { - app.configure(rest()); + app.configure(rest() as any); assert.ok(false, 'Should never get here'); } catch (e: any) { assert.strictEqual(e.message, '@feathersjs/express/rest needs an Express compatible app.'); @@ -83,8 +83,8 @@ describe('@feathersjs/express/rest provider', () => { before(async () => { app = expressify(feathers()) - .configure(rest(express.formatter)) .use(express.json()) + .configure(rest(express.formatter)) .use('codes', { async get (id: Id) { return { id }; @@ -264,12 +264,12 @@ describe('@feathersjs/express/rest provider', () => { }; const app = expressify(feathers()) - .configure(rest(express.formatter)) .use(function (req: Request, _res: Response, next: NextFunction) { assert.ok(req.feathers, 'Feathers object initialized'); req.feathers.test = 'Happy'; next(); }) + .configure(rest(express.formatter)) .use('service', service); const server = await app.listen(4778); @@ -301,8 +301,8 @@ describe('@feathersjs/express/rest provider', () => { req.headers['content-type'] = req.headers['content-type'] || 'application/json'; next(); }) - .configure(rest(express.formatter)) .use(express.json()) + .configure(rest(express.formatter)) .use('/todo', { async create (data: any) { return data; @@ -326,8 +326,9 @@ describe('@feathersjs/express/rest provider', () => { it('allows middleware before and after a service', async () => { const app = expressify(feathers()); - app.configure(rest()) + app .use(express.json()) + .configure(rest()) .use('/todo', function (req, _res, next) { req.body.before = ['before first']; next(); @@ -361,8 +362,9 @@ describe('@feathersjs/express/rest provider', () => { it('allows middleware arrays before and after a service', async () => { const app = expressify(feathers()); - app.configure(rest()) + app .use(express.json()) + .configure(rest()) .use('/todo', [function (req: Request, _res: Response, next: NextFunction) { req.body.before = ['before first']; next(); @@ -405,8 +407,9 @@ describe('@feathersjs/express/rest provider', () => { res.data.push(req.body.text); res.status(200).json(res.data); }]; - app.configure(rest()) + app .use(express.json()) + .configure(rest()) .use('/array-middleware', middlewareArray); const server = await app.listen(4776); @@ -576,8 +579,8 @@ describe('@feathersjs/express/rest provider', () => { before(async () => { app = expressify(feathers()) - .configure(rest()) .use(express.json()) + .configure(rest()) .use('/todo', new Service(), { methods: ['find', 'customMethod'] }) diff --git a/packages/feathers/src/application.ts b/packages/feathers/src/application.ts index d0aba120ff..9676be9398 100644 --- a/packages/feathers/src/application.ts +++ b/packages/feathers/src/application.ts @@ -93,7 +93,7 @@ export class Feathers extends EventEmitter implements Feathe } const protoService = wrapService(location, service, options); - const serviceOptions = getServiceOptions(service, options); + const serviceOptions = getServiceOptions(protoService); for (const name of protectedMethods) { if (serviceOptions.methods.includes(name)) { diff --git a/packages/koa/package.json b/packages/koa/package.json index 755751a051..20a3b34ad5 100644 --- a/packages/koa/package.json +++ b/packages/koa/package.json @@ -56,6 +56,7 @@ "@types/koa-qs": "^2.0.0", "koa": "^2.13.4", "koa-bodyparser": "^4.3.0", + "koa-compose": "^4.1.0", "koa-qs": "^3.0.0" }, "devDependencies": { @@ -63,8 +64,10 @@ "@feathersjs/feathers": "^5.0.0-pre.15", "@feathersjs/memory": "^5.0.0-pre.15", "@feathersjs/tests": "^5.0.0-pre.15", + "@types/koa-compose": "^3.2.5", "@types/mocha": "^9.0.0", "@types/node": "^17.0.5", + "axios": "^0.24.0", "mocha": "^9.1.3", "shx": "^0.3.3", "ts-node": "^10.4.0", diff --git a/packages/koa/src/authenticate.ts b/packages/koa/src/authenticate.ts deleted file mode 100644 index b68d121251..0000000000 --- a/packages/koa/src/authenticate.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Next } from 'koa'; -import { createDebug } from '@feathersjs/commons'; - -import { FeathersKoaContext } from './declarations'; - -const debug = createDebug('@feathersjs/koa:authentication'); - -interface MiddlewareSettings { - service?: string; - strategies?: string[]; -} - -export function authentication (settings: MiddlewareSettings = {}) { - return async (ctx: FeathersKoaContext, next: Next) => { - const { app } = ctx; - const service = app.defaultAuthentication ? app.defaultAuthentication(settings.service) : null; - - if (service === null) { - return next(); - } - - const config = service.configuration; - const authStrategies = settings.strategies || config.parseStrategies || config.authStrategies || []; - - if (authStrategies.length === 0) { - debug('No `authStrategies` or `parseStrategies` found in authentication configuration'); - return next(); - } - - const { req, res } = ctx as any; - const authentication = await service.parse(req, res, ...authStrategies); - - if (authentication) { - debug('Parsed authentication from HTTP header', authentication); - ctx.feathers.authentication = authentication; - } - - return next(); - }; -} diff --git a/packages/koa/src/authentication.ts b/packages/koa/src/authentication.ts new file mode 100644 index 0000000000..f0730c6374 --- /dev/null +++ b/packages/koa/src/authentication.ts @@ -0,0 +1,56 @@ +import { Application, HookContext } from '@feathersjs/feathers'; +import { createDebug } from '@feathersjs/commons'; +import { authenticate as AuthenticateHook } from '@feathersjs/authentication'; + +import { Middleware } from './declarations'; + +const debug = createDebug('@feathersjs/koa/authentication'); + +export type AuthenticationSettings = { + service?: string; + strategies?: string[]; +}; + +export function parseAuthentication (settings: AuthenticationSettings = {}): Middleware { + return async (ctx, next) => { + const app = ctx.app; + const service = app.defaultAuthentication?.(settings.service); + + if (!service) { + return next(); + } + + const config = service.configuration; + const authStrategies = settings.strategies || config.parseStrategies || config.authStrategies || []; + + if (authStrategies.length === 0) { + debug('No `authStrategies` or `parseStrategies` found in authentication configuration'); + return next(); + } + + const authentication = await service.parse(ctx.req, ctx.res, ...authStrategies); + + if (authentication) { + debug('Parsed authentication from HTTP header', authentication); + ctx.feathers = { ...ctx.feathers, authentication }; + } + + return next(); + }; +} + +export function authenticate (settings: string | AuthenticationSettings, ...strategies: string[]): Middleware { + const hook = AuthenticateHook(settings, ...strategies); + + return async (ctx, next) => { + const app = ctx.app as Application; + const params = ctx.feathers; + const context = { app, params } as HookContext; + + await hook(context); + + ctx.feathers = context.params; + + return next(); + }; +} diff --git a/packages/koa/src/declarations.ts b/packages/koa/src/declarations.ts index 13292463e1..506a72d462 100644 --- a/packages/koa/src/declarations.ts +++ b/packages/koa/src/declarations.ts @@ -1,6 +1,6 @@ -import Koa from 'koa'; +import Koa, { Next } from 'koa'; import { Server } from 'http'; -import { Application as FeathersApplication, HookContext, Params } from '@feathersjs/feathers'; +import { Application as FeathersApplication, HookContext, Params, RouteLookup } from '@feathersjs/feathers'; import '@feathersjs/authentication'; export type ApplicationAddons = { @@ -14,9 +14,22 @@ export type FeathersKoaContext = Koa.Context & { app: A; }; +export type Middleware = (context: FeathersKoaContext, next: Next) => any; + +declare module '@feathersjs/feathers/lib/declarations' { + interface ServiceOptions { + koa?: { + before?: Middleware[]; + after?: Middleware[]; + composed?: Middleware; + }; + } +} + declare module 'koa' { interface ExtendableContext { feathers?: Partial; + lookup?: RouteLookup; hook?: HookContext; } } diff --git a/packages/koa/src/error-handler.ts b/packages/koa/src/handlers.ts similarity index 98% rename from packages/koa/src/error-handler.ts rename to packages/koa/src/handlers.ts index d8fd13d93f..228f529a0e 100644 --- a/packages/koa/src/error-handler.ts +++ b/packages/koa/src/handlers.ts @@ -8,7 +8,7 @@ export const errorHandler = () => async (ctx: FeathersKoaContext, next: () => Pr if(ctx.body === undefined) { throw new NotFound('Not Found'); } - + } catch (error: any) { ctx.response.status = error.code || 500; ctx.body = typeof error.toJSON === 'function' ? error.toJSON() : { diff --git a/packages/koa/src/index.ts b/packages/koa/src/index.ts index 6d8f884a73..1e19ca4933 100644 --- a/packages/koa/src/index.ts +++ b/packages/koa/src/index.ts @@ -1,39 +1,38 @@ import Koa from 'koa'; -import bodyParser from 'koa-bodyparser'; import koaQs from 'koa-qs'; import { Application as FeathersApplication } from '@feathersjs/feathers'; import { routing } from '@feathersjs/transport-commons'; import { createDebug } from '@feathersjs/commons'; import { Application } from './declarations'; -import { errorHandler } from './error-handler'; -const debug = createDebug('@feathersjs/koa'); +export { default as Koa } from 'koa'; +export { default as bodyParser } from 'koa-bodyparser'; +export * from './authentication'; export * from './declarations'; -export * from './authenticate'; -export { rest } from './rest'; -export { Koa, bodyParser, errorHandler }; +export * from './handlers'; +export * from './rest'; -export function koa (_app?: FeathersApplication): Application { - const koaApp = new Koa(); +const debug = createDebug('@feathersjs/koa'); - if (!_app) { - return koaApp as unknown as Application; +export function koa (feathersApp?: FeathersApplication, koaApp: Koa = new Koa()): Application { + if (!feathersApp) { + return koaApp as any; } - if (typeof _app.setup !== 'function') { + if (typeof feathersApp.setup !== 'function') { throw new Error('@feathersjs/koa requires a valid Feathers application instance'); } - const app = _app as Application; + const app = feathersApp as any as Application; const { listen: koaListen, use: koaUse } = koaApp; - const oldUse = app.use; + const feathersUse = feathersApp.use as any; Object.assign(app, { use (location: string|Koa.Middleware, ...args: any[]) { if (typeof location === 'string') { - return (oldUse as any).call(this, location, ...args); + return feathersUse.call(this, location, ...args); } return koaUse.call(this, location); @@ -49,31 +48,31 @@ export function koa (_app?: FeathersApplication): Application { } } as Application); - const feathersDescriptors = { + const appDescriptors = { ...Object.getOwnPropertyDescriptors(Object.getPrototypeOf(app)), ...Object.getOwnPropertyDescriptors(app) }; - const koaDescriptors = { + const newDescriptors = { ...Object.getOwnPropertyDescriptors(Object.getPrototypeOf(koaApp)), ...Object.getOwnPropertyDescriptors(koaApp) }; // Copy all non-existing properties (including non-enumerables) // that don't already exist on the Express app - Object.keys(koaDescriptors).forEach(prop => { - const feathersProp = feathersDescriptors[prop]; - const koaProp = koaDescriptors[prop]; + Object.keys(newDescriptors).forEach(prop => { + const appProp = appDescriptors[prop]; + const newProp = newDescriptors[prop]; - if (koaProp !== undefined && feathersProp === undefined) { - Object.defineProperty(app, prop, koaProp); + if (appProp === undefined && newProp !== undefined) { + Object.defineProperty(app, prop, newProp); } }); koaQs(app as any); - app.configure(routing()); - app.use((ctx, next) => { - ctx.feathers = { provider: 'rest' }; + app.configure(routing() as any); + app.use((ctx, next) => { + ctx.feathers = { ...ctx.feathers, provider: 'rest' }; return next(); }); diff --git a/packages/koa/src/rest.ts b/packages/koa/src/rest.ts index fc32d6cf9d..5b1182bc53 100644 --- a/packages/koa/src/rest.ts +++ b/packages/koa/src/rest.ts @@ -1,52 +1,93 @@ -import { Next } from 'koa'; +import compose from 'koa-compose'; import { http } from '@feathersjs/transport-commons'; import { createDebug } from '@feathersjs/commons'; import { getServiceOptions, defaultServiceMethods, createContext } from '@feathersjs/feathers'; import { MethodNotAllowed } from '@feathersjs/errors'; -import { FeathersKoaContext } from './declarations'; - -const debug = createDebug('@feathersjs/koa:rest'); - -export function rest () { - return async (ctx: FeathersKoaContext, next: Next) => { - const { app, request } = ctx; - const { query: koaQuery, headers, path, body: data, method: httpMethod } = request; - const query = { ...koaQuery }; - const methodOverride = request.headers[http.METHOD_HEADER] ? - request.headers[http.METHOD_HEADER] as string : null; - const lookup = app.lookup(path); - - if (lookup !== null) { - const { service, params: { __id: id = null, ...route } = {} } = lookup; - const method = http.getServiceMethod(httpMethod, id, methodOverride); - const { methods } = getServiceOptions(service); - - debug(`Found service for path ${path}, attempting to run '${method}' service method`); - - if (!methods.includes(method) || defaultServiceMethods.includes(methodOverride)) { - ctx.response.status = http.statusCodes.methodNotAllowed; - - throw new MethodNotAllowed(`Method \`${method}\` is not supported by this endpoint.`); - } - - const createArguments = (http.argumentsFor as any)[method] || http.argumentsFor.default; - const params = { - ...ctx.feathers, - query, - headers, - route - }; - const args = createArguments({ id, data, params }); - const hookContext = createContext(service, method, { http: {} }); - - ctx.hook = hookContext as any; - - const result = await (service as any)[method](...args, hookContext); - - ctx.response.status = http.getStatusCode(result, {}); - ctx.body = http.getData(result); + +import { Application, Middleware } from './declarations'; +import { AuthenticationSettings, parseAuthentication } from './authentication'; + +const debug = createDebug('@feathersjs/koa/rest'); + +const serviceMiddleware = (): Middleware => { + return async (ctx, next) => { + const { query, headers, path, body: data, method: httpMethod } = ctx.request; + const methodOverride = ctx.request.headers[http.METHOD_HEADER] as string | undefined; + + const { service, params: { __id: id = null, ...route } = {} } = ctx.lookup!; + const method = http.getServiceMethod(httpMethod, id, methodOverride); + const { methods } = getServiceOptions(service); + + debug(`Found service for path ${path}, attempting to run '${method}' service method`); + + if (!methods.includes(method) || defaultServiceMethods.includes(methodOverride)) { + const error = new MethodNotAllowed(`Method \`${method}\` is not supported by this endpoint.`); + ctx.response.status = error.code; + throw error; } + const createArguments = http.argumentsFor[method as 'get'] || http.argumentsFor.default; + const params = { query, headers, route, ...ctx.feathers }; + const args = createArguments({ id, data, params }); + const contextBase = createContext(service, method, { http: {} }); + ctx.hook = contextBase; + + const context = await (service as any)[method](...args, contextBase); + ctx.hook = context; + + const result = http.getData(context); + const statusCode = http.getStatusCode(context, result); + + ctx.body = result; + ctx.status = statusCode; + return next(); }; -} +}; + +const servicesMiddleware = (): Middleware => { + return async (ctx, next) => { + const app = ctx.app; + const lookup = app.lookup(ctx.request.path); + + if (!lookup) { + return next(); + } + + ctx.lookup = lookup; + + const options = getServiceOptions(lookup.service); + const middleware = options.koa!.composed!; + + return middleware(ctx, next); + }; +}; + +export const formatter: Middleware = (_ctx, _next) => {}; + +export type RestOptions = { + formatter?: Middleware; + authentication?: AuthenticationSettings; +}; + +export const rest = (options?: RestOptions | Middleware) => { + options = typeof options === 'function' ? { formatter: options } : options || {}; + + const formatterMiddleware = options.formatter || formatter; + const authenticationOptions = options.authentication; + + return (app: Application) => { + app.use(parseAuthentication(authenticationOptions)); + app.use(servicesMiddleware()); + + app.mixins.push((_service, _path, options) => { + const { koa: { before = [], after = [] } = {} } = options; + + const middlewares = [].concat(before, serviceMiddleware(), after, formatterMiddleware); + const middleware = compose(middlewares); + + options.koa ||= {}; + options.koa.composed = middleware; + }); + }; +}; diff --git a/packages/koa/test/app.fixture.ts b/packages/koa/test/app.fixture.ts index 87ea0cc080..a0936251ad 100644 --- a/packages/koa/test/app.fixture.ts +++ b/packages/koa/test/app.fixture.ts @@ -3,16 +3,15 @@ import { feathers, Params, HookContext } from '@feathersjs/feathers'; import { authenticate, AuthenticationService, JWTStrategy } from '@feathersjs/authentication'; import { LocalStrategy, hooks } from '@feathersjs/authentication-local'; -import { koa, rest, bodyParser, errorHandler, authentication } from '../src'; +import { koa, rest, bodyParser, errorHandler } from '../src'; const { protect, hashPassword } = hooks; const app = koa(feathers()); const authService = new AuthenticationService(app); app.use(errorHandler()); -app.use(authentication()); app.use(bodyParser()); -app.use(rest()); +app.configure(rest()); app.set('authentication', { entity: 'user', service: 'users', diff --git a/packages/koa/test/index.test.ts b/packages/koa/test/index.test.ts index eebac20af9..475d748681 100644 --- a/packages/koa/test/index.test.ts +++ b/packages/koa/test/index.test.ts @@ -14,22 +14,24 @@ describe('@feathersjs/koa', () => { app = koa(feathers()); app.use(errorHandler()); app.use(bodyParser()); - app.use(rest()); - app.use('/', new Service()); - app.use('todo', new Service(), { - methods: [ - 'get', 'find', 'create', 'update', - 'patch', 'remove', 'customMethod' - ] - }); - app.use(ctx => { + app.use(async (ctx, next) => { if (ctx.request.path === '/middleware') { ctx.body = { feathers: ctx.feathers, message: 'Hello from middleware' }; + } else { + await next(); } }); + app.configure(rest()); + app.use('/', new Service()); + app.use('todo', new Service(), { + methods: [ + 'get', 'find', 'create', 'update', + 'patch', 'remove', 'customMethod' + ] + }); server = await app.listen(8465); }); diff --git a/packages/rest-client/test/server.ts b/packages/rest-client/test/server.ts index 905a1d351f..c738499612 100644 --- a/packages/rest-client/test/server.ts +++ b/packages/rest-client/test/server.ts @@ -77,6 +77,15 @@ class TodoService extends Service { export default (configurer?: any) => { const app = expressify(feathers()) + .use(function (req, res, next) { + res.header('Access-Control-Allow-Origin', '*'); + res.header('Access-Control-Allow-Headers', 'Authorization'); + req.feathers.authorization = req.headers.authorization; + next(); + }) + // Parse HTTP bodies + .use(json()) + .use(urlencoded({ extended: true })) .configure(rest(function formatter (_req, res, next) { if (!res.data) { next(); @@ -92,15 +101,6 @@ export default (configurer?: any) => { } }); })) - .use(function (req, res, next) { - res.header('Access-Control-Allow-Origin', '*'); - res.header('Access-Control-Allow-Headers', 'Authorization'); - req.feathers.authorization = req.headers.authorization; - next(); - }) - // Parse HTTP bodies - .use(json()) - .use(urlencoded({ extended: true })) // Host our Todos service on the /todos path .use('/todos', new TodoService(), { methods: [ diff --git a/packages/tests/package.json b/packages/tests/package.json index c8769f9f22..b330a6e9e2 100644 --- a/packages/tests/package.json +++ b/packages/tests/package.json @@ -41,13 +41,13 @@ "access": "public" }, "dependencies": { - "@types/axios": "^0.14.0", "@types/lodash": "^4.14.178", "axios": "^0.24.0", "lodash": "^4.17.21" }, "devDependencies": { "@feathersjs/feathers": "^5.0.0-pre.15", + "@types/axios": "^0.14.0", "@types/mocha": "^9.0.0", "@types/node": "^17.0.5", "mocha": "^9.1.3", diff --git a/packages/transport-commons/src/channels/mixins.ts b/packages/transport-commons/src/channels/mixins.ts index 5f0fdfbd1b..06861043aa 100644 --- a/packages/transport-commons/src/channels/mixins.ts +++ b/packages/transport-commons/src/channels/mixins.ts @@ -4,7 +4,7 @@ import { createDebug } from '@feathersjs/commons'; import { Channel } from './channel/base'; import { CombinedChannel } from './channel/combined'; -const debug = createDebug('@feathersjs/transport-commons:channels/mixins'); +const debug = createDebug('@feathersjs/transport-commons/channels/mixins'); const PUBLISHERS = Symbol('@feathersjs/transport-commons/publishers'); const CHANNELS = Symbol('@feathersjs/transport-commons/channels'); const ALL_EVENTS = Symbol('@feathersjs/transport-commons/all-events');