Skip to content

Commit d284ad8

Browse files
jannyHoubajtos
authored andcommitted
feat: body validation
Co-authored-by: Miroslav Bajtos <mbajtoss@gmail.com>
1 parent a69c004 commit d284ad8

File tree

21 files changed

+525
-65
lines changed

21 files changed

+525
-65
lines changed

examples/todo/src/controllers/todo.controller.ts

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,7 @@
55

66
import {inject} from '@loopback/core';
77
import {repository} from '@loopback/repository';
8-
import {
9-
HttpErrors,
10-
del,
11-
get,
12-
param,
13-
patch,
14-
post,
15-
put,
16-
requestBody,
17-
} from '@loopback/rest';
8+
import {del, get, param, patch, post, put, requestBody} from '@loopback/rest';
189
import {Todo} from '../models';
1910
import {TodoRepository} from '../repositories';
2011
import {GeocoderService} from '../services';
@@ -27,12 +18,6 @@ export class TodoController {
2718

2819
@post('/todos')
2920
async createTodo(@requestBody() todo: Todo) {
30-
// TODO(bajtos) This should be handled by the framework
31-
// See https://github.com/strongloop/loopback-next/issues/118
32-
if (!todo.title) {
33-
throw new HttpErrors.BadRequest('title is required');
34-
}
35-
3621
if (todo.remindAtAddress) {
3722
// TODO(bajtos) handle "address not found"
3823
const geo = await this.geoService.geocode(todo.remindAtAddress);

examples/todo/test/acceptance/application.acceptance.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,15 @@ describe('Application', () => {
4747
expect(result).to.containDeep(todo);
4848
});
4949

50+
it('rejects requests to create a todo with no title', async () => {
51+
const todo = givenTodo();
52+
delete todo.title;
53+
await client
54+
.post('/todos')
55+
.send(todo)
56+
.expect(422);
57+
});
58+
5059
it('creates an address-based reminder', async function() {
5160
// Increase the timeout to accommodate slow network connections
5261
// tslint:disable-next-line:no-invalid-this

examples/todo/test/unit/controllers/todo.controller.unit.ts

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,6 @@ describe('TodoController', () => {
4747
let aChangedTodo: Todo;
4848
let aTodoList: Todo[];
4949

50-
const noError = 'No error was thrown!';
51-
5250
beforeEach(resetRepositories);
5351

5452
describe('createTodo', () => {
@@ -59,20 +57,6 @@ describe('TodoController', () => {
5957
sinon.assert.calledWith(create, aTodo);
6058
});
6159

62-
it('throws if the payload is missing a title', async () => {
63-
const todo = givenTodo();
64-
delete todo.title;
65-
try {
66-
await controller.createTodo(todo);
67-
} catch (err) {
68-
expect(err).to.match(/title is required/);
69-
sinon.assert.notCalled(create);
70-
return;
71-
}
72-
// Repository stub should not have been called!
73-
throw new Error(noError);
74-
});
75-
7660
it('resolves remindAtAddress to a geocode', async () => {
7761
geocode.resolves([aLocation.geopoint]);
7862

packages/openapi-v3/src/controller-spec.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,16 +178,34 @@ function resolveControllerSpec(constructor: Function): ControllerSpec {
178178
if (!spec.components.schemas) {
179179
spec.components.schemas = {};
180180
}
181+
if (p.name in spec.components.schemas) {
182+
// Preserve user-provided definitions
183+
debug(
184+
' skipping parameter type %j as already defined',
185+
p.name || p,
186+
);
187+
continue;
188+
}
181189
const jsonSchema = getJsonSchema(p);
182190
const openapiSchema = jsonToSchemaObject(jsonSchema);
191+
const outputSchemas = spec.components.schemas;
183192
if (openapiSchema.definitions) {
184193
for (const key in openapiSchema.definitions) {
185-
spec.components.schemas[key] = openapiSchema.definitions[key];
194+
// Preserve user-provided definitions
195+
if (key in outputSchemas) continue;
196+
const relatedSchema = openapiSchema.definitions[key];
197+
debug(
198+
' defining referenced schema for %j: %j',
199+
key,
200+
relatedSchema,
201+
);
202+
outputSchemas[key] = relatedSchema;
186203
}
187204
delete openapiSchema.definitions;
188205
}
189206

190-
spec.components.schemas[p.name] = openapiSchema;
207+
debug(' defining schema for %j: %j', p.name, openapiSchema);
208+
outputSchemas[p.name] = openapiSchema;
191209
break;
192210
}
193211
}

packages/openapi-v3/src/decorators/request-body.decorator.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ export const REQUEST_BODY_INDEX = 'x-parameter-index';
7979
*/
8080
export function requestBody(requestBodySpec?: Partial<RequestBodyObject>) {
8181
return function(target: Object, member: string, index: number) {
82+
debug('@requestBody() on %s.%s', target.constructor.name, member);
83+
debug(' parameter index: %s', index);
84+
debug(' options: %s', inspect(requestBodySpec, {depth: null}));
85+
8286
// Use 'application/json' as default content if `requestBody` is undefined
8387
requestBodySpec = requestBodySpec || {content: {}};
8488

@@ -89,11 +93,12 @@ export function requestBody(requestBodySpec?: Partial<RequestBodyObject>) {
8993
const methodSig = MetadataInspector.getDesignTypeForMethod(target, member);
9094
const paramTypes = (methodSig && methodSig.parameterTypes) || [];
9195

92-
let paramType = paramTypes[index];
93-
let schema: SchemaObject;
96+
const paramType = paramTypes[index];
97+
const schema = getSchemaForRequestBody(paramType);
98+
debug(' inferred schema: %s', inspect(schema, {depth: null}));
9499
requestBodySpec.content = _.mapValues(requestBodySpec.content, c => {
95100
if (!c.schema) {
96-
c.schema = schema = schema || getSchemaForRequestBody(paramType);
101+
c.schema = schema;
97102
}
98103
return c;
99104
});
@@ -104,9 +109,7 @@ export function requestBody(requestBodySpec?: Partial<RequestBodyObject>) {
104109
requestBodySpec[REQUEST_BODY_INDEX] = index;
105110
}
106111

107-
debug('requestBody member: ', member);
108-
debug('requestBody index: ', index);
109-
debug('requestBody spec: ', inspect(requestBodySpec, {depth: null}));
112+
debug(' final spec: ', inspect(requestBodySpec, {depth: null}));
110113
ParameterDecoratorFactory.createDecorator<RequestBodyObject>(
111114
OAI3Keys.REQUEST_BODY_KEY,
112115
requestBodySpec as RequestBodyObject,

packages/openapi-v3/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,5 @@
66
export * from './decorators';
77
export * from './controller-spec';
88
export * from './json-to-schema';
9+
10+
export * from '@loopback/repository-json-schema';

packages/openapi-v3/test/unit/decorators/request-body/request-body.decorator.unit.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ describe('requestBody decorator', () => {
7070
expect(requestBodySpec.content).to.deepEqual(expectedContent);
7171
});
7272

73-
it('schema in requestBody overrides the generated schema', () => {
73+
it('preserves user-provided schema in requestBody', () => {
7474
const expectedContent = {
7575
'application/json': {
7676
schema: {type: 'object'},
@@ -93,6 +93,29 @@ describe('requestBody decorator', () => {
9393
expect(requestBodySpec.content).to.deepEqual(expectedContent);
9494
});
9595

96+
it('preserves user-provided reference in requestBody', () => {
97+
const expectedContent = {
98+
'application/json': {
99+
schema: {$ref: '#/components/schemas/MyModel'},
100+
},
101+
};
102+
103+
class MyModel {}
104+
105+
class MyController {
106+
@post('/MyModel')
107+
createMyModel(
108+
@requestBody({content: expectedContent})
109+
inst: Partial<MyModel>,
110+
) {}
111+
}
112+
113+
const requestBodySpec = getControllerSpec(MyController).paths['/MyModel'][
114+
'post'
115+
].requestBody;
116+
expect(requestBodySpec.content).to.deepEqual(expectedContent);
117+
});
118+
96119
it('reports error if more than one requestBody are found for the same method', () => {
97120
class MyController {
98121
@post('/greeting')

packages/rest/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,15 @@
3131
"@types/cors": "^2.8.3",
3232
"@types/express": "^4.11.1",
3333
"@types/http-errors": "^1.6.1",
34+
"ajv": "^6.5.1",
3435
"body": "^5.1.0",
3536
"cors": "^2.8.4",
3637
"debug": "^3.1.0",
3738
"express": "^4.16.3",
3839
"http-errors": "^1.6.3",
3940
"js-yaml": "^3.11.0",
4041
"lodash": "^4.17.5",
42+
"openapi-schema-to-json-schema": "^2.1.0",
4143
"path-to-regexp": "^2.2.0",
4244
"validator": "^10.4.0"
4345
},

packages/rest/src/coercion/rest-http-error.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,7 @@ export namespace RestHttpErrors {
1212
const msg = `Parameters with "in: ${location}" are not supported yet.`;
1313
return new HttpErrors.NotImplemented(msg);
1414
}
15+
export function invalidRequestBody(msg: string): HttpErrors.HttpError {
16+
return new HttpErrors.UnprocessableEntity(msg);
17+
}
1518
}

packages/rest/src/http-handler.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@ export class HttpHandler {
5555
}
5656

5757
findRoute(request: Request): ResolvedRoute {
58-
return this._routes.find(request);
58+
const route = this._routes.find(request);
59+
Object.assign(route.schemas, this.getApiDefinitions());
60+
return route;
5961
}
6062

6163
protected async _handleRequest(

0 commit comments

Comments
 (0)