From 92292aaee1d479ea8576409d10f2b1c1b018880b Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Fri, 13 Mar 2020 10:20:35 -0700 Subject: [PATCH 1/4] feat(openapi-v3): add sugar decorators for file requestBody/response --- .../request-body-shortcut.decorator.unit.ts | 40 +++++++++-- .../decorators/response.decorator.unit.ts | 51 +++++++++++++- .../src/decorators/request-body.decorator.ts | 67 ++++++++++++++++++- .../src/decorators/response.decorator.ts | 46 ++++++++++++- 4 files changed, 194 insertions(+), 10 deletions(-) diff --git a/packages/openapi-v3/src/__tests__/unit/decorators/request-body/request-body-shortcut.decorator.unit.ts b/packages/openapi-v3/src/__tests__/unit/decorators/request-body/request-body-shortcut.decorator.unit.ts index 3ef6355b9374..ed2566964409 100644 --- a/packages/openapi-v3/src/__tests__/unit/decorators/request-body/request-body-shortcut.decorator.unit.ts +++ b/packages/openapi-v3/src/__tests__/unit/decorators/request-body/request-body-shortcut.decorator.unit.ts @@ -4,7 +4,7 @@ // License text available at https://opensource.org/licenses/MIT import {expect} from '@loopback/testlab'; -import {getControllerSpec, requestBody, post} from '../../../../'; +import {getControllerSpec, post, requestBody} from '../../../../'; describe('requestBody decorator - shortcuts', () => { context('array', () => { @@ -13,10 +13,7 @@ describe('requestBody decorator - shortcuts', () => { class MyController { @post('/greeting') greet( - @requestBody.array( - {type: 'string'}, - {description: description, required: false}, - ) + @requestBody.array({type: 'string'}, {description, required: false}) name: string[], ) {} } @@ -33,10 +30,41 @@ describe('requestBody decorator - shortcuts', () => { const requestBodySpec = actualSpec.paths['/greeting']['post'].requestBody; expect(requestBodySpec).to.have.properties({ - description: description, + description, required: false, content: expectedContent, }); }); }); + + context('file', () => { + it('generates the correct schema spec for a file argument', () => { + const description = 'a picture'; + class MyController { + @post('/pictures') + upload( + @requestBody.file({description, required: true}) + request: unknown, // It should be `Request` from `@loopback/rest` + ) {} + } + + const actualSpec = getControllerSpec(MyController); + const expectedContent = { + 'multipart/form-data': { + 'x-parser': 'stream', + schema: { + type: 'object', + properties: {file: {type: 'string', format: 'binary'}}, + }, + }, + }; + + const requestBodySpec = actualSpec.paths['/pictures']['post'].requestBody; + expect(requestBodySpec).to.have.properties({ + description, + required: true, + content: expectedContent, + }); + }); + }); }); diff --git a/packages/openapi-v3/src/__tests__/unit/decorators/response.decorator.unit.ts b/packages/openapi-v3/src/__tests__/unit/decorators/response.decorator.unit.ts index 4885cc2830f2..c818265dc43c 100644 --- a/packages/openapi-v3/src/__tests__/unit/decorators/response.decorator.unit.ts +++ b/packages/openapi-v3/src/__tests__/unit/decorators/response.decorator.unit.ts @@ -7,7 +7,7 @@ import {Model, model, property} from '@loopback/repository'; import {expect} from '@loopback/testlab'; import * as httpStatus from 'http-status'; import {ResponseObject} from 'openapi3-ts'; -import {get, getControllerSpec, oas} from '../../..'; +import {get, getControllerSpec, oas, param} from '../../..'; describe('@oas.response decorator', () => { it('allows a class to not be decorated with @oas.response at all', () => { @@ -201,4 +201,53 @@ describe('@oas.response decorator', () => { ).to.eql({$ref: '#/components/schemas/SuccessModel'}); }); }); + + context('@oas.response.file', () => { + it('allows @oas.response.file with media types', () => { + class MyController { + @get('/files/{filename}') + @oas.response.file('image/jpeg', 'image/png') + download(@param.path.string('filename') fileName: string) { + // use response.download(...); + } + } + + const actualSpec = getControllerSpec(MyController); + expect(actualSpec.paths['/files/{filename}'].get.responses['200']).to.eql( + { + description: 'The file content', + content: { + 'image/jpeg': { + schema: {type: 'string', format: 'binary'}, + }, + 'image/png': { + schema: {type: 'string', format: 'binary'}, + }, + }, + }, + ); + }); + + it('allows @oas.response.file without media types', () => { + class MyController { + @get('/files/{filename}') + @oas.response.file() + download(@param.path.string('filename') filename: string) { + // use response.download(...); + } + } + + const actualSpec = getControllerSpec(MyController); + expect(actualSpec.paths['/files/{filename}'].get.responses['200']).to.eql( + { + description: 'The file content', + content: { + 'application/octet-stream': { + schema: {type: 'string', format: 'binary'}, + }, + }, + }, + ); + }); + }); }); diff --git a/packages/openapi-v3/src/decorators/request-body.decorator.ts b/packages/openapi-v3/src/decorators/request-body.decorator.ts index b262130d69fe..691d2b2288ec 100644 --- a/packages/openapi-v3/src/decorators/request-body.decorator.ts +++ b/packages/openapi-v3/src/decorators/request-body.decorator.ts @@ -144,10 +144,10 @@ export namespace requestBody { * @param properties - The requestBody properties other than `content` * @param itemSpec - the full item object */ - export const array = function( + export const array = ( itemSpec: SchemaObject | ReferenceObject, properties?: {description?: string; required?: boolean}, - ) { + ) => { return requestBody({ ...properties, content: { @@ -157,4 +157,67 @@ export namespace requestBody { }, }); }; + + /** + * Define a requestBody of `file` type. This is used to support + * multipart/form-data based file upload. Use `@requestBody` for other content + * types. + * + * {@link https://swagger.io/docs/specification/describing-request-body/file-upload | OpenAPI file upload} + * + * @example + * import {Request} from '@loopback/rest'; + * + * ```ts + * class MyController { + * @post('/pictures') + * upload( + * @requestBody.file() + * request: Request, + * ) { + * // ... + * } + * } + * ``` + * + * @param properties - Optional description and required flag + */ + export const file = (properties?: { + description?: string; + required?: boolean; + }) => { + return requestBody({ + description: 'Request body for multipart/form-data based file upload', + required: true, + content: { + // Media type for file upload + 'multipart/form-data': { + // Skip body parsing + 'x-parser': 'stream', + schema: { + type: 'object', + properties: { + file: { + type: 'string', + // This is required by OpenAPI spec 3.x for file upload + format: 'binary', + }, + // Multiple file upload is not working with swagger-ui + // https://github.com/swagger-api/swagger-ui/issues/4600 + /* + files: { + type: 'array', + items: { + type: 'string', + format: 'binary', + }, + }, + */ + }, + }, + }, + }, + ...properties, + }); + }; } diff --git a/packages/openapi-v3/src/decorators/response.decorator.ts b/packages/openapi-v3/src/decorators/response.decorator.ts index 7014f96b109c..8c99ccb4d27f 100644 --- a/packages/openapi-v3/src/decorators/response.decorator.ts +++ b/packages/openapi-v3/src/decorators/response.decorator.ts @@ -41,7 +41,7 @@ function buildDecoratorReducer( | SchemaObject | ReferenceObject, contentType: ct, - description, + description: m.description ?? description, }); }); } else { @@ -81,3 +81,47 @@ export function response( {decoratorName: '@response', allowInheritance: false}, ); } + +export namespace response { + /** + * Decorate the response as a file + * + * @example + * ```ts + * import {oas, get, param} from '@loopback/openapi-v3'; + * import {RestBindings, Response} from '@loopback/rest'; + * + * class MyController { + * @get('/files/{filename}') + * @oas.response.file('image/jpeg', 'image/png') + * download( + * @param.path.string('filename') fileName: string, + * @inject(RestBindings.Http.RESPONSE) response: Response, + * ) { + * // use response.download(...); + * } + * } + * ``` + * @param mediaTypes - A list of media types for the file response. It's + * default to `['application/octet-stream']`. + */ + export const file = (...mediaTypes: string[]) => { + if (mediaTypes.length === 0) { + mediaTypes = ['application/octet-stream']; + } + const responseWithContent: ResponseWithContent = { + content: {}, + description: 'The file content', + }; + for (const t of mediaTypes) { + responseWithContent.content[t] = { + schema: { + type: 'string', + format: 'binary', // This is required by OpenAPI spec 3.x + }, + }; + } + + return response(200, responseWithContent); + }; +} From 0fe0c27b1b66d5ee57c546da7ad1410e34b6f5d2 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Fri, 13 Mar 2020 10:28:56 -0700 Subject: [PATCH 2/4] feat(docs): add docs for @requestBody.file and @oas.respone.file --- docs/site/decorators/Decorators_openapi.md | 40 ++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/docs/site/decorators/Decorators_openapi.md b/docs/site/decorators/Decorators_openapi.md index 33fc59ff5b91..37f8a5277555 100644 --- a/docs/site/decorators/Decorators_openapi.md +++ b/docs/site/decorators/Decorators_openapi.md @@ -357,6 +357,25 @@ class MyController { } ``` +#### @requestBody.file + +`@requestBody.file` marks a request body for `multipart/form-data` based file +upload. For example, + +```ts +import {post, requestBody} from '@loopback/openapi-v3'; +import {Request} from '@loopback/rest'; +class MyController { + @post('/pictures') + upload( + @requestBody.file() + request: Request, + ) { + // ... + } +} +``` + _We plan to support more `@requestBody` shortcuts in the future. You can track the feature in [story 1064](https://github.com/strongloop/loopback-next/issues/1064)._ @@ -757,6 +776,27 @@ class MyController { } ``` +#### Using @oas.response.file + +`@oas.response.file` is a shortcut decorator to describe response object for +file download. For example: + +```ts +import {oas, get, param} from '@loopback/openapi-v3'; +import {RestBindings, Response} from '@loopback/rest'; + +class MyController { + @get('/files/{filename}') + @oas.response.file('image/jpeg', 'image/png') + download( + @param.path.string('filename') fileName: string, + @inject(RestBindings.Http.RESPONSE) response: Response, + ) { + // use response.download(...); + } +} +``` + #### Using more options The `@oas.response` convenience decorator makes some assumptions for you in From 8ed92b059000a0bb1ae7b881b02d8987ff6db042 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Fri, 13 Mar 2020 10:31:14 -0700 Subject: [PATCH 3/4] feat(example-file-upload-download): use @requestBody.file and @oas.response.file --- .../controllers/file-download.controller.ts | 29 +++++++--------- .../src/controllers/file-upload.controller.ts | 33 +------------------ 2 files changed, 12 insertions(+), 50 deletions(-) diff --git a/examples/file-upload-download/src/controllers/file-download.controller.ts b/examples/file-upload-download/src/controllers/file-download.controller.ts index af449c57ac85..22bb322a2e39 100644 --- a/examples/file-upload-download/src/controllers/file-download.controller.ts +++ b/examples/file-upload-download/src/controllers/file-download.controller.ts @@ -4,7 +4,14 @@ // License text available at https://opensource.org/licenses/MIT import {inject} from '@loopback/context'; -import {get, HttpErrors, param, Response, RestBindings} from '@loopback/rest'; +import { + get, + HttpErrors, + oas, + param, + Response, + RestBindings, +} from '@loopback/rest'; import fs from 'fs'; import path from 'path'; import {promisify} from 'util'; @@ -40,23 +47,9 @@ export class FileDownloadController { return files; } - @get('/files/{filename}', { - responses: { - 200: { - content: { - // file - 'application/octet-stream': { - schema: { - type: 'string', - format: 'binary', // This is required by OpenAPI spec 3.x - }, - }, - }, - description: 'The file content', - }, - }, - }) - async downloadFile( + @get('/files/{filename}') + @oas.response.file() + downloadFile( @param.path.string('filename') fileName: string, @inject(RestBindings.Http.RESPONSE) response: Response, ) { diff --git a/examples/file-upload-download/src/controllers/file-upload.controller.ts b/examples/file-upload-download/src/controllers/file-upload.controller.ts index cd7ca76460ba..2807e99a3eec 100644 --- a/examples/file-upload-download/src/controllers/file-upload.controller.ts +++ b/examples/file-upload-download/src/controllers/file-upload.controller.ts @@ -40,38 +40,7 @@ export class FileUploadController { }, }) async fileUpload( - @requestBody({ - description: 'multipart/form-data for files/fields', - required: true, - content: { - // Media type for file upload - 'multipart/form-data': { - // Skip body parsing - 'x-parser': 'stream', - schema: { - type: 'object', - properties: { - file: { - type: 'string', - // This is required by OpenAPI spec 3.x for file upload - format: 'binary', - }, - // Multiple file upload is not working with swagger-ui - // https://github.com/swagger-api/swagger-ui/issues/4600 - /* - filename: { - type: 'array', - items: { - type: 'string', - format: 'binary', - }, - }, - */ - }, - }, - }, - }, - }) + @requestBody.file() request: Request, @inject(RestBindings.Http.RESPONSE) response: Response, ): Promise { From a6088a6b47976be08ae11faeb79d4ad4a3aa0d67 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Fri, 13 Mar 2020 11:52:37 -0700 Subject: [PATCH 4/4] feat(docs): add file upload/download to usage scenarios --- docs/site/File-upload-download.md | 301 +++++++++++++++++++++++++++++ docs/site/Usage-scenarios.md | 1 + docs/site/sidebars/lb4_sidebar.yml | 6 +- 3 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 docs/site/File-upload-download.md diff --git a/docs/site/File-upload-download.md b/docs/site/File-upload-download.md new file mode 100644 index 000000000000..434a79e3721a --- /dev/null +++ b/docs/site/File-upload-download.md @@ -0,0 +1,301 @@ +--- +lang: en +title: 'Upload and download files' +keywords: LoopBack 4.0, LoopBack-Next +sidebar: lb4_sidebar +permalink: /doc/en/lb4/File-upload-download.html +--- + +## File upload and download + +It's a common requirement for API applications to support file upload and +download. This page describes how to expose REST APIs for uploading and +downloading files using LoopBack 4. It also illustrates how to build a simple +Web UI to interact with such APIs. + +A fully-functional example is available at +[@loopback/example-file-upload-download](https://github.com/strongloop/loopback-next/tree/master/examples/file-upload-download). +We use code snippets from the example to walk through the key artifacts. + +### File upload + +A few steps are involved to create an endpoint for file upload. + +1. Create a controller class such as + [`FileUploadController`](https://github.com/strongloop/loopback-next/blob/master/examples/file-upload-download/src/controllers/file-upload.controller.ts) + +```ts +import {inject} from '@loopback/context'; +import { + post, + Request, + requestBody, + Response, + RestBindings, +} from '@loopback/rest'; +import {FILE_UPLOAD_SERVICE} from '../keys'; +import {FileUploadHandler} from '../types'; +/** + * A controller to handle file uploads using multipart/form-data media type + */ +export class FileUploadController { + /** + * Constructor + * @param handler - Inject an express request handler to deal with the request + */ + constructor( + @inject(FILE_UPLOAD_SERVICE) private handler: FileUploadHandler, + ) {} +} +``` + +In the example, we inject an instance of `FileUploadService` backed by +[multer](https://github.com/expressjs/multer) to process the incoming http +request. The +[`FileUploadService`](https://github.com/strongloop/loopback-next/blob/master/examples/file-upload-download/src/services/file-upload.service.ts) +is configurable to support various storage engines. + +2. Add a method for file upload + +```ts +/** + * A controller to handle file uploads using multipart/form-data media type + */ +export class FileUploadController { + @post('/files', { + responses: { + 200: { + content: { + 'application/json': { + schema: { + type: 'object', + }, + }, + }, + description: 'Files and fields', + }, + }, + }) + async fileUpload( + @requestBody.file() + request: Request, + @inject(RestBindings.Http.RESPONSE) response: Response, + ): Promise { + return new Promise((resolve, reject) => { + this.handler(request, response, err => { + if (err) reject(err); + else { + resolve(FileUploadController.getFilesAndFields(request)); + } + }); + }); + } +} +``` + +The `@post` decoration exposes the method over `POST /files` endpoint to accept +file upload. We also apply `@requestBody.file()` to mark the request body to be +files being uploaded using `multipart/form-data` content type. The injected +`request` and `response` objects are passed into the `multer` handler to process +the stream, saving to `.sandbox` directory in our example. + +See more details about `@requestBody.file` in +[OpenAPI decorators](decorators/Decorators_openapi.md#requestbodyfile). + +### File download + +To download files from the backend, please follow the following steps. + +1. Create a controller class such as + [`FileDownloadController`](https://github.com/strongloop/loopback-next/blob/master/examples/file-upload-download/src/controllers/file-download.controller.ts) + +```ts +import {inject} from '@loopback/context'; +import { + get, + HttpErrors, + oas, + param, + Response, + RestBindings, +} from '@loopback/rest'; +import fs from 'fs'; +import path from 'path'; +import {promisify} from 'util'; + +const readdir = promisify(fs.readdir); + +const SANDBOX = path.resolve(__dirname, '../../.sandbox'); + +/** + * A controller to handle file downloads using multipart/form-data media type + */ +export class FileDownloadController {} +``` + +2. (optional) Add a method to list available files + +```ts +export class FileDownloadController { + @get('/files', { + responses: { + 200: { + content: { + // string[] + 'application/json': { + schema: { + type: 'array', + items: { + type: 'string', + }, + }, + }, + }, + description: 'A list of files', + }, + }, + }) + async listFiles() { + const files = await readdir(SANDBOX); + return files; + } +``` + +The `@get` decorator exposes `GET /files` to list available file. Our example +implementation simply returns an array of file names under the `.sandbox` +directory. + +3. Add a method to download a file by name + +```ts +export class FileDownloadController { + @get('/files/{filename}') + @oas.response.file() + downloadFile( + @param.path.string('filename') fileName: string, + @inject(RestBindings.Http.RESPONSE) response: Response, + ) { + const file = validateFileName(fileName); + response.download(file, fileName); + return response; + } +} + +/** + * Validate file names to prevent them goes beyond the designated directory + * @param fileName - File name + */ +function validateFileName(fileName: string) { + const resolved = path.resolve(SANDBOX, fileName); + if (resolved.startsWith(SANDBOX)) return resolved; + // The resolved file is outside sandbox + throw new HttpErrors.BadRequest(`Invalid file name: ${fileName}`); +} +``` + +The `@get` decorator exposes `GET /files/{filename}` for file download. We use +`response.download` from `Express` to send the file. + +The decoration of `@oas.response.file()` sets the OpenAPI response object for +file download. See more details about `@oas.response.file` in +[OpenAPI decorators](decorators/Decorators_openapi.md#using-oasresponsefile). + +{% include note.html content=" +The `downloadFile` returns `response` as-is to instruct LoopBack to skip the +response serialization step as `response.download` manipulates the `response` +stream directly. +" %} + +{% include warning.html content=" +The `fileName` argument is from user input. We have to validate the value to +prevent the request to access files outside the `.sandbox` directory. The +`validateFileName` method resolves the file by name and rejects the request if +the file is outside the sandbox. +" %} + +### Build a simple UI + +The example application comes with a +[simple HTML page](https://github.com/strongloop/loopback-next/blob/master/examples/file-upload-download/public/index.html). + +The page contains the following JavaScript functions: + +```js +/** + * Submit the upload form + */ +function setupUploadForm() { + const formElem = document.getElementById('uploadForm'); + formElem.onsubmit = async e => { + e.preventDefault(); + const res = await fetch('/files', { + method: 'POST', + body: new FormData(formElem), + }); + const body = await res.json(); + console.log('Response from upload', body); + await fetchFiles(); + }; +} +/** + * List uploaded files + */ +async function fetchFiles() { + const res = await fetch('/files'); + const files = await res.json(); + console.log('Response from list', files); + const list = files.map(f => `
  • ${f}
  • \n`); + document.getElementById('fileList').innerHTML = list.join(''); +} +async function init() { + setupUploadForm(); + await fetchFiles(); +} +``` + +The page has two key divisions: + +- A form for file selection and upload +- A list of files with URL links to be downloaded + +```html + +
    +

    File upload and download

    + +
    +

    Upload files

    +
    + +

    + + +

    + +
    +
    + +
    +

    Download files

    +
      + +
      + +

      OpenAPI spec: /openapi.json

      +

      API Explorer: /explorer

      +
      + + + +``` diff --git a/docs/site/Usage-scenarios.md b/docs/site/Usage-scenarios.md index 731f1dae64e4..f2dff2fd64c6 100644 --- a/docs/site/Usage-scenarios.md +++ b/docs/site/Usage-scenarios.md @@ -14,4 +14,5 @@ databases, integrating to other infrastructures and calling other services. - [call other services](Calling-other-APIs-and-Web-Services.md) - [integrate with infrastructures](Integrate-with-infrastructures.md) - [serve static files](Serving-static-files.md) +- [upload and download files](File-upload-download.md) - [deploy to cloud](Deployment.md) diff --git a/docs/site/sidebars/lb4_sidebar.yml b/docs/site/sidebars/lb4_sidebar.yml index e5878359f7cd..9ab2ba65620c 100644 --- a/docs/site/sidebars/lb4_sidebar.yml +++ b/docs/site/sidebars/lb4_sidebar.yml @@ -191,10 +191,14 @@ children: url: Appsody-LoopBack.html output: 'web, pdf' - - title: 'Serving static files' + - title: 'Serving Static Files' url: Serving-static-files.html output: 'web, pdf' + - title: 'Uploading and Downloading Files' + url: File-upload-download.html + output: 'web, pdf' + - title: 'Behind the Scene' url: Behind-the-scene.html output: 'web, pdf'