Skip to content

Commit

Permalink
Add support for file uploads
Browse files Browse the repository at this point in the history
  • Loading branch information
Ezra Quemuel committed May 9, 2017
1 parent d52d3f5 commit 7f68a0a
Show file tree
Hide file tree
Showing 24 changed files with 1,160 additions and 295 deletions.
5 changes: 4 additions & 1 deletion .gitignore
@@ -1,3 +1,6 @@
dist
node_modules
typings
typings
.idea/
uploads/
uploads2/
49 changes: 49 additions & 0 deletions README.MD
Expand Up @@ -156,6 +156,55 @@ Note that the parameter `request` does not appear in your swagger definition fil
Likewise you can use the decorator `@Inject` to mark a parameter as being injected manually and should be omitted in swagger generation.
In this case you should write your own custom template where you inject the needed objects/values in the method-call.

### File Uploads

To add file upload support to your route
```typescript
import {File, UploadedFile, UploadedFiles} from 'tsoa';

@Route('SomeRoute')
export class SomeController {
/**
* For single file uploads
* curl \
* -X POST \
* -F "aFile=@/home/user1/Desktop/test.jpg" \
* localhost/SomeRoute/Files
*/
@Post('Files')
public async upload(@UploadedFile('aFile') file: File) {
// Do something with file..
}
//
/**
* If posting multiple files e.g.
* curl \
* -X POST \
* -F "aFile=@/home/user1/Desktop/test.jpg" \
* -F "aFile=@/home/user1/Desktop/test2.jpg" \
* localhost/SomeRoute/ManyFiles
*/
@Post('Files')
public async upload(@UploadedFile('aFile') file: File[]) {
// Do something with files..
}
}
```

To configure where uploads are stored, in your tsoa config:
```json
{
"swagger": ...,
"routes": {
"basePath": ...,
"entryFile": ...,
"routesDir": ...,
"middleware": ...,
"uploadDirectory": "/home/user/someUploadDirectory"
}
}
```

### Dependency injection / IOC

By default all the controllers are created by the auto-generated routes template using an empty default constructor.
Expand Down
3 changes: 2 additions & 1 deletion custom-tsoa.json
Expand Up @@ -47,6 +47,7 @@
"routesDir": "./tests/fixtures/custom",
"middleware": "express",
"middlewareTemplate": "custom-tsoa-template.ts",
"authenticationModule": "./tests/fixtures/custom/authentication.ts"
"authenticationModule": "./tests/fixtures/custom/authentication.ts",
"uploadDirectory": "./uploads2"
}
}
3 changes: 3 additions & 0 deletions package.json
Expand Up @@ -38,10 +38,12 @@
"express": "^4.14.1",
"handlebars": "^4.0.6",
"handlebars-helpers": "^0.8.0",
"koa-multer": "^1.0.1",
"lodash": "^4.17.4",
"merge": "^1.2.0",
"mkdirp": "^0.5.1",
"moment": "^2.17.1",
"multer": "^1.3.0",
"tslint": "^4.4.2",
"typescript": "^2.2.1",
"typescript-formatter": "^5.1.0",
Expand All @@ -63,6 +65,7 @@
"@types/minimatch": "^2.0.29",
"@types/mkdirp": "^0.3.29",
"@types/mocha": "^2.2.39",
"@types/multer": "^0.0.33",
"@types/node": "^7.0.5",
"@types/serve-static": "^1.7.31",
"@types/superagent": "^2.0.36",
Expand Down
5 changes: 5 additions & 0 deletions src/config.ts
Expand Up @@ -121,4 +121,9 @@ export interface RoutesConfig {
* Authentication Module for express, hapi and koa
*/
authenticationModule?: string;

/**
* Directory to store file uploads
*/
uploadDirectory?: string;
}
18 changes: 18 additions & 0 deletions src/decorators/parameter.ts
Expand Up @@ -48,3 +48,21 @@ export function Query(name?: string): any {
export function Header(name?: string): any {
return () => { return; };
};

/**
* Inject uploaded file
*
* @param {string} [name] The name of the uploaded file parameter
*/
export function UploadedFile(name?: string): any {
return () => { return; };
};

/**
* Inject uploaded files
*
* @param {string} [name] The name of the uploaded files parameter
*/
export function UploadedFiles(name?: string): any {
return () => { return; };
};
6 changes: 5 additions & 1 deletion src/index.ts
@@ -1,10 +1,11 @@
import { Example } from './decorators/example';
import { Request, Query, Path, Body, BodyProp, Header } from './decorators/parameter';
import { Request, Query, Path, Body, BodyProp, Header, UploadedFile, UploadedFiles } from './decorators/parameter';
import { Post, Get, Patch, Delete, Put } from './decorators/methods';
import { Tags } from './decorators/tags';
import { Route } from './decorators/route';
import { Security } from './decorators/security';
import { Controller } from './interfaces/controller';
import { File } from './interfaces/file';
import { Response, SuccessResponse } from './decorators/response';
import { ValidateParam } from './routeGeneration/templateHelpers';

Expand All @@ -22,9 +23,12 @@ export {
Body,
BodyProp,
Header,
UploadedFile,
UploadedFiles,
Response,
SuccessResponse,
Controller,
File,
Route,
Security,
ValidateParam,
Expand Down
1 change: 1 addition & 0 deletions src/interfaces/file.ts
@@ -0,0 +1 @@
export type File = Express.Multer.File;
44 changes: 42 additions & 2 deletions src/metadataGeneration/parameterGenerator.ts
Expand Up @@ -26,6 +26,10 @@ export class ParameterGenerator {
return this.getQueryParameter(this.parameter);
case 'Path':
return this.getPathParameter(this.parameter);
case 'UploadedFile':
return this.getUploadedFileParameter(this.parameter);
case 'UploadedFiles':
return this.getUploadedFilesParameter(this.parameter);
default:
return this.getPathParameter(this.parameter);
}
Expand Down Expand Up @@ -143,6 +147,42 @@ export class ParameterGenerator {
};
}

private getUploadedFileParameter(parameter: ts.ParameterDeclaration): Parameter {
const parameterName = (parameter.name as ts.Identifier).text;
const type = {typeName: 'file'};

if (!this.supportPathDataType(type)) {
throw new InvalidParameterException(`Parameter '${parameterName}:${type}' can't be passed as an uploaded file parameter in '${this.getCurrentLocation()}'.`);
}

return {
description: this.getParameterDescription(parameter),
in: 'formData',
name: getDecoratorTextValue(this.parameter, ident => ident.text === 'UploadedFile') || parameterName,
required: true,
type,
parameterName
};
}

private getUploadedFilesParameter(parameter: ts.ParameterDeclaration): Parameter {
const parameterName = (parameter.name as ts.Identifier).text;
const type = {typeName: 'file[]'};

if (!this.supportPathDataType(type)) {
throw new InvalidParameterException(`Parameter '${parameterName}:${type}' can't be passed as an uploaded files parameter in '${this.getCurrentLocation()}'.`);
}

return {
description: this.getParameterDescription(parameter),
in: 'formData',
name: getDecoratorTextValue(this.parameter, ident => ident.text === 'UploadedFiles') || parameterName,
required: true,
type,
parameterName
};
}

private getParameterDescription(node: ts.ParameterDeclaration) {
const symbol = MetadataGenerator.current.typeChecker.getSymbolAtLocation(node.name);

Expand All @@ -157,11 +197,11 @@ export class ParameterGenerator {
}

private supportParameterDecorator(decoratorName: string) {
return ['header', 'query', 'parem', 'body', 'bodyprop', 'request'].some(d => d === decoratorName.toLocaleLowerCase());
return ['header', 'query', 'parem', 'body', 'bodyprop', 'request', 'uploadedfile', 'uploadedfiles'].some(d => d === decoratorName.toLocaleLowerCase());
}

private supportPathDataType(parameterType: Type) {
return ['string', 'integer', 'long', 'float', 'double', 'date', 'datetime', 'buffer', 'boolean', 'enum'].find(t => t === parameterType.typeName);
return ['string', 'integer', 'long', 'float', 'double', 'date', 'datetime', 'buffer', 'boolean', 'enum', 'file', 'file[]'].find(t => t === parameterType.typeName);
}

private getValidatedType(parameter: ts.ParameterDeclaration) {
Expand Down
17 changes: 16 additions & 1 deletion src/routeGeneration/routeGenerator.ts
Expand Up @@ -80,12 +80,19 @@ export class RouteGenerator {
parameters[parameter.parameterName] = this.getParameterSchema(parameter);
});

const uploadFileParameter = method.parameters.find(parameter => parameter.type.typeName === 'file');
const uploadFilesParameter = method.parameters.find(parameter => parameter.type.typeName === 'file[]');

return {
method: method.method.toLowerCase(),
name: method.name,
parameters,
path: pathTransformer(method.path),
security: method.security
security: method.security,
uploadFile: !!uploadFileParameter,
uploadFileName: uploadFileParameter && uploadFileParameter.name,
uploadFiles: !!uploadFilesParameter,
uploadFilesName: uploadFilesParameter && uploadFilesParameter.name,
};
}),
modulePath: this.getRelativeImportPath(controller.location),
Expand All @@ -96,6 +103,14 @@ export class RouteGenerator {
environment: process.env,
iocModule,
models: this.getModels(),
uploadDirectory: this.options.uploadDirectory || './uploads',
useFileUploads: this.metadata.Controllers.some(
controller => controller.methods.some(
method => !!method.parameters.find(
parameter => parameter.type.typeName === 'file' || parameter.type.typeName === 'file[]'
)
)
),
useSecurity: this.metadata.Controllers.some(
controller => controller.methods.some(methods => methods.security !== undefined)
)
Expand Down
24 changes: 22 additions & 2 deletions src/routeGeneration/templates/express.ts
Expand Up @@ -18,6 +18,12 @@ import { set } from 'lodash';
{{#if authenticationModule}}
import { expressAuthentication } from '{{authenticationModule}}';
{{/if}}
{{#if useFileUploads}}
import * as multer from 'multer';

const upload = multer({dest: '{{uploadDirectory}}'});
{{/if}}


const models: any = {
{{#each models}}
Expand Down Expand Up @@ -50,6 +56,12 @@ export function RegisterRoutes(app: any) {
,{{{json security.scopes}}}
{{/if}}),
{{/if}}
{{#if uploadFile}}
upload.single('{{uploadFileName}}'),
{{/if}}
{{#if uploadFiles}}
upload.array('{{uploadFilesName}}'),
{{/if}}
function (request: any, response: any, next: any) {
const args = {
{{#each parameters}}
Expand Down Expand Up @@ -117,15 +129,23 @@ export function RegisterRoutes(app: any) {
case 'request':
return request;
case 'query':
return ValidateParam(args[key], request.query[name], models, name)
return ValidateParam(args[key], request.query[name], models, name);
case 'path':
return ValidateParam(args[key], request.params[name], models, name)
return ValidateParam(args[key], request.params[name], models, name);
case 'header':
return ValidateParam(args[key], request.header(name), models, name);
case 'body':
return ValidateParam(args[key], request.body, models, name);
case 'body-prop':
return ValidateParam(args[key], request.body[name], models, name);
case 'formData':
if (args[key].typeName === 'file') {
return ValidateParam(args[key], request.file, models, name);
} else if (args[key].typeName === 'file[]') {
return ValidateParam(args[key], request.files, models, name);
} else {
return ValidateParam(args[key], request.body[name], models, name);
}
}
});
}
Expand Down

0 comments on commit 7f68a0a

Please sign in to comment.