From a1cefccea1583aa1889d60b15088b85cb5e5d190 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Wed, 15 Aug 2018 09:09:22 -0700 Subject: [PATCH] feat(rest): allow static assets to be served by a rest server --- docs/site/Application.md | 31 ++++- packages/rest/src/rest.application.ts | 15 +++ packages/rest/src/rest.server.ts | 37 ++++++ .../rest/test/integration/fixtures/index.html | 8 ++ .../integration/rest.server.integration.ts | 107 +++++++++++++++++- 5 files changed, 195 insertions(+), 3 deletions(-) create mode 100644 packages/rest/test/integration/fixtures/index.html diff --git a/docs/site/Application.md b/docs/site/Application.md index 8a8e83178de7..de164929f50d 100644 --- a/docs/site/Application.md +++ b/docs/site/Application.md @@ -206,10 +206,39 @@ This means you can call these `RestServer` functions to do all of your server-level setups in the app constructor without having to explicitly retrieve an instance of your server. +### Serve static files + +The `RestServer` allows static files to be served. It can be set up by calling +the `static()` API. + +```ts +app.static('/html', rootDirForHtml); +``` + +or + +```ts +server.static(['/html', '/public'], rootDirForHtml); +``` + +Static assets are not allowed to be mounted on `/` to avoid performance penalty +as `/` matches all paths and incurs file system access for each HTTP request. + +The static() API delegates to +[serve-static](https://expressjs.com/en/resources/middleware/serve-static.html) +to serve static files. Please see +https://expressjs.com/en/starter/static-files.html and +https://expressjs.com/en/4x/api.html#express.static for details. + +**WARNING**: + +> The static assets are served before LoopBack sequence of actions. If an error +> is thrown, the `reject` action will NOT be triggered. + ### Use unique bindings Use binding names that are prefixed with a unique string that does not overlap -with loopback's bindings. As an example, if your application is built for your +with LoopBack's bindings. As an example, if your application is built for your employer FooCorp, you can prefix your bindings with `fooCorp`. ```ts diff --git a/packages/rest/src/rest.application.ts b/packages/rest/src/rest.application.ts index a74c53928ea8..8b979a5a3708 100644 --- a/packages/rest/src/rest.application.ts +++ b/packages/rest/src/rest.application.ts @@ -16,6 +16,8 @@ import { ControllerFactory, } from './router/routing-table'; import {OperationObject, OpenApiSpec} from '@loopback/openapi-v3-types'; +import {ServeStaticOptions} from 'serve-static'; +import {PathParams} from 'express-serve-static-core'; export const ERR_NO_MULTI_SERVER = format( 'RestApplication does not support multiple servers!', @@ -83,6 +85,19 @@ export class RestApplication extends Application implements HttpServerLike { this.restServer.handler(handlerFn); } + /** + * Mount static assets to the REST server. + * See https://expressjs.com/en/4x/api.html#express.static + * @param path The path(s) to serve the asset. + * See examples at https://expressjs.com/en/4x/api.html#path-examples + * To avoid performance penalty, `/` is not allowed for now. + * @param rootDir The root directory from which to serve static assets + * @param options Options for serve-static + */ + static(path: PathParams, rootDir: string, options?: ServeStaticOptions) { + this.restServer.static(path, rootDir, options); + } + /** * Register a new Controller-based route. * diff --git a/packages/rest/src/rest.server.ts b/packages/rest/src/rest.server.ts index 9b48012a38ba..64258e2b30d7 100644 --- a/packages/rest/src/rest.server.ts +++ b/packages/rest/src/rest.server.ts @@ -35,6 +35,9 @@ import { import {RestBindings} from './keys'; import {RequestContext} from './request-context'; import * as express from 'express'; +import {ServeStaticOptions} from 'serve-static'; +import {PathParams} from 'express-serve-static-core'; +import * as pathToRegExp from 'path-to-regexp'; const debug = require('debug')('loopback:rest:server'); @@ -131,6 +134,7 @@ export class RestServer extends Context implements Server, HttpServerLike { protected _httpServer: HttpServer | undefined; protected _expressApp: express.Application; + protected _routerForStaticAssets: express.Router; get listening(): boolean { return this._httpServer ? this._httpServer.listening : false; @@ -196,6 +200,9 @@ export class RestServer extends Context implements Server, HttpServerLike { }; this._expressApp.use(cors(corsOptions)); + // Place the assets router here before controllers + this._setupRouterForStaticAssets(); + // Mount our router & request handler this._expressApp.use((req, res, next) => { this._handleHttpRequest(req, res, options!).catch(next); @@ -209,6 +216,17 @@ export class RestServer extends Context implements Server, HttpServerLike { ); } + /** + * Set up an express router for all static assets so that middleware for + * all directories are invoked at the same phase + */ + protected _setupRouterForStaticAssets() { + if (!this._routerForStaticAssets) { + this._routerForStaticAssets = express.Router(); + this._expressApp.use(this._routerForStaticAssets); + } + } + protected _handleHttpRequest( request: Request, response: Response, @@ -513,6 +531,25 @@ export class RestServer extends Context implements Server, HttpServerLike { ); } + /** + * Mount static assets to the REST server. + * See https://expressjs.com/en/4x/api.html#express.static + * @param path The path(s) to serve the asset. + * See examples at https://expressjs.com/en/4x/api.html#path-examples + * To avoid performance penalty, `/` is not allowed for now. + * @param rootDir The root directory from which to serve static assets + * @param options Options for serve-static + */ + static(path: PathParams, rootDir: string, options?: ServeStaticOptions) { + const re = pathToRegExp(path, [], {end: false}); + if (re.test('/')) { + throw new Error( + 'Static assets cannot be mount to "/" to avoid performance penalty.', + ); + } + this._routerForStaticAssets.use(path, express.static(rootDir, options)); + } + /** * Set the OpenAPI specification that defines the REST API schema for this * server. All routes, parameter definitions and return types will be defined diff --git a/packages/rest/test/integration/fixtures/index.html b/packages/rest/test/integration/fixtures/index.html new file mode 100644 index 000000000000..75283a67d708 --- /dev/null +++ b/packages/rest/test/integration/fixtures/index.html @@ -0,0 +1,8 @@ + +
+ Test Page +
+ +

Hello, World!

+ + diff --git a/packages/rest/test/integration/rest.server.integration.ts b/packages/rest/test/integration/rest.server.integration.ts index 4b78e22d9f22..fe596f18e7da 100644 --- a/packages/rest/test/integration/rest.server.integration.ts +++ b/packages/rest/test/integration/rest.server.integration.ts @@ -3,7 +3,7 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {Application, ApplicationConfig} from '@loopback/core'; +import {Application} from '@loopback/core'; import { supertest, expect, @@ -17,6 +17,7 @@ import {IncomingMessage, ServerResponse} from 'http'; import * as yaml from 'js-yaml'; import * as path from 'path'; import * as fs from 'fs'; +import {RestServerConfig} from '../../src'; describe('RestServer (integration)', () => { it('exports url property', async () => { @@ -77,6 +78,108 @@ describe('RestServer (integration)', () => { .expect(500); }); + it('does not allow static assets to be mounted at /', async () => { + const root = path.join(__dirname, 'fixtures'); + const server = await givenAServer({ + rest: { + port: 0, + }, + }); + + expect(() => server.static('/', root)).to.throw( + 'Static assets cannot be mount to "/" to avoid performance penalty.', + ); + + expect(() => server.static('', root)).to.throw( + 'Static assets cannot be mount to "/" to avoid performance penalty.', + ); + + expect(() => server.static(['/'], root)).to.throw( + 'Static assets cannot be mount to "/" to avoid performance penalty.', + ); + + expect(() => server.static(['/html', ''], root)).to.throw( + 'Static assets cannot be mount to "/" to avoid performance penalty.', + ); + + expect(() => server.static(/.*/, root)).to.throw( + 'Static assets cannot be mount to "/" to avoid performance penalty.', + ); + + expect(() => server.static('/(.*)', root)).to.throw( + 'Static assets cannot be mount to "/" to avoid performance penalty.', + ); + }); + + it('allows static assets via api', async () => { + const root = path.join(__dirname, 'fixtures'); + const server = await givenAServer({ + rest: { + port: 0, + }, + }); + + server.static('/html', root); + const content = fs + .readFileSync(path.join(root, 'index.html')) + .toString('utf-8'); + await createClientForHandler(server.requestHandler) + .get('/html/index.html') + .expect('Content-Type', /text\/html/) + .expect(200, content); + }); + + it('allows static assets via api after start', async () => { + const root = path.join(__dirname, 'fixtures'); + const server = await givenAServer({ + rest: { + port: 0, + }, + }); + await createClientForHandler(server.requestHandler) + .get('/html/index.html') + .expect(404); + + server.static('/html', root); + + await createClientForHandler(server.requestHandler) + .get('/html/index.html') + .expect(200); + }); + + it('allows non-static routes after assets', async () => { + const root = path.join(__dirname, 'fixtures'); + const server = await givenAServer({ + rest: { + port: 0, + }, + }); + server.static('/html', root); + server.handler(dummyRequestHandler); + + await createClientForHandler(server.requestHandler) + .get('/html/does-not-exist.html') + .expect(200, 'Hello'); + }); + + it('serve static assets if matches before other routes', async () => { + const root = path.join(__dirname, 'fixtures'); + const server = await givenAServer({ + rest: { + port: 0, + }, + }); + server.static('/html', root); + server.handler(dummyRequestHandler); + + const content = fs + .readFileSync(path.join(root, 'index.html')) + .toString('utf-8'); + await createClientForHandler(server.requestHandler) + .get('/html/index.html') + .expect(200, content); + }); + it('allows cors', async () => { const server = await givenAServer({rest: {port: 0}}); server.handler(dummyRequestHandler); @@ -369,7 +472,7 @@ servers: await server.stop(); }); - async function givenAServer(options?: ApplicationConfig) { + async function givenAServer(options?: {rest: RestServerConfig}) { const app = new Application(options); app.component(RestComponent); return await app.getServer(RestServer);