Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add in i18n support for validator #161

Merged
merged 9 commits into from
Apr 30, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -602,7 +602,9 @@ Middy factory function. Use it to wrap your existing handler to enable middlewar

## middlewareFunction ⇒ <code>void</code> \| <code>Promise</code>
**Kind**: global typedef
**Returns**: <code>void</code> \| <code>Promise</code> - - A middleware can return a Promise instead of using the `next` function as a callback. In this case middy will wait for the promise to resolve (or reject) and it will automatically propagate the result to the next middleware.
**Returns**: <code>void</code> \| <code>Promise</code> - - A middleware can return a Promise instead of using the `next` function as a callback.
In this case middy will wait for the promise to resolve (or reject) and it will automatically
propagate the result to the next middleware.

| Param | Type | Description |
| --- | --- | --- |
Expand Down
6 changes: 4 additions & 2 deletions docs/middlewares.md
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,7 @@ handler

Fetches parameters from [AWS Systems Manager Parameter Store](https://docs.aws.amazon.com/systems-manager/latest/userguide/systems-manager-paramstore.html).

Parameters to fetch can be defined by path and by name (not mutually exclusive). See AWS docs [here](https://aws.amazon.com/blogs/mt/organize-parameters-by-hierarchy-tags-or-amazon-cloudwatch-events-with-amazon-ec2-systems-manager-parameter-store/).
Parameters to fetch can be defined by path and by name (not mutually exclusive). See AWS docs [here](https://aws.amazon.com/blogs/mt/organize-parameters-by-hierarchy-tags-or-amazon-cloudwatch-events-with-amazon-ec2-systems-manager-parameter-store/).

By default parameters are assigned to the Node.js `process.env` object. They can instead be assigned to the function handler's `context` object by setting the `setToContext` flag to `true`. By default all parameters are added with uppercase names.

Expand Down Expand Up @@ -529,14 +529,16 @@ This middleware can be used in combination with
[`httpErrorHandler`](#httperrorhandler) to automatically return the right
response to the user.

It can also be used in combination with [`httpcontentnegotiation`](#httpContentNegotiation) to load localised translations for the error messages (based on the currently requested language). This feature uses internally [`ajv-i18n`](http://npm.im/ajv-i18n) module, so reference to this module for options and more advanced use cases. By default the language used will be English (`en`), but you can redefine the default language by passing it in the `ajvOptions` options with the key `defaultLanguage` and specifying as value one of the [supported locales](https://www.npmjs.com/package/ajv-i18n#supported-locales).

### Options

- `inputSchema` (object) (optional): The JSON schema object that will be used
to validate the input (`handler.event`) of the Lambda handler.
- `outputSchema` (object) (optional): The JSON schema object that will be used
to validate the output (`handler.response`) of the Lambda handler.
- `ajvOptions` (object) (optional): Options to pass to [ajv](https://epoberezkin.github.io/ajv/)
class constructor. Defaults are `{v5: true, $data: true, allErrors: true}`
class constructor. Defaults are `{v5: true, coerceTypes: 'array', $data: true, allErrors: true, useDefaults: true, defaultLanguage: 'en'}`

### Sample Usage

Expand Down
7 changes: 6 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "middy",
"version": "0.12.1",
"version": "0.13.0",
"description": "🛵 The stylish Node.js middleware engine for AWS Lambda",
"main": "./index.js",
"files": [
Expand Down Expand Up @@ -67,6 +67,7 @@
"@types/aws-lambda": "^8.10.1",
"@types/http-errors": "^1.6.1",
"ajv": "^6.0.0",
"ajv-i18n": "^3.1.0",
"ajv-keywords": "^3.0.0",
"content-type": "^1.0.4",
"http-errors": "^1.6.2",
Expand Down
69 changes: 69 additions & 0 deletions src/middlewares/__tests__/validator.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,75 @@ describe('📦 Middleware Validator', () => {
}
handler(event, {}, (err, res) => {
expect(err.message).toEqual('Event object failed validation')
expect(err.details).toEqual([{'dataPath': '', 'keyword': 'required', 'message': 'should have required property foo', 'params': {'missingProperty': 'foo'}, 'schemaPath': '#/required'}])
})
})

test('It should handle invalid schema as a BadRequest in a different language', () => {
const handler = middy((event, context, cb) => {
cb(null, event.body) // propagates the body as a response
})

const schema = {
required: ['body', 'foo'],
properties: {
// this will pass validation
body: {
type: 'string'
},
// this won't as it won't be in the event
foo: {
type: 'string'
}
}
}

handler.use(validator({
inputSchema: schema
}))

// invokes the handler, note that property foo is missing
const event = {
preferredLanguage: 'fr',
body: JSON.stringify({something: 'somethingelse'})
}
handler(event, {}, (err, res) => {
expect(err.message).toEqual('Event object failed validation')
expect(err.details).toEqual([{'dataPath': '', 'keyword': 'required', 'message': 'requiert la propriété foo', 'params': {'missingProperty': 'foo'}, 'schemaPath': '#/required'}])
})
})

test('It should handle invalid schema as a BadRequest in a different language (with normalization)', () => {
const handler = middy((event, context, cb) => {
cb(null, event.body) // propagates the body as a response
})

const schema = {
required: ['body', 'foo'],
properties: {
// this will pass validation
body: {
type: 'string'
},
// this won't as it won't be in the event
foo: {
type: 'string'
}
}
}

handler.use(validator({
inputSchema: schema
}))

// invokes the handler, note that property foo is missing
const event = {
preferredLanguage: 'pt',
body: JSON.stringify({something: 'somethingelse'})
}
handler(event, {}, (err, res) => {
expect(err.message).toEqual('Event object failed validation')
expect(err.details).toEqual([{'dataPath': '', 'keyword': 'required', 'message': 'deve ter a propriedade requerida foo', 'params': {'missingProperty': 'foo'}, 'schemaPath': '#/required'}])
})
})

Expand Down
43 changes: 39 additions & 4 deletions src/middlewares/validator.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,44 @@
const createError = require('http-errors')
const Ajv = require('ajv')
const ajvKeywords = require('ajv-keywords')
const {deepEqual} = require('assert')
const ajvLocalize = require('ajv-i18n')
const { deepEqual } = require('assert')

let ajv
let previousConstructorOptions
const defaults = {v5: true, $data: true, allErrors: true}
const defaults = {
v5: true,
coerceTypes: 'array', // important for query string params
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, isn't this a breaking change? coerceTypes is false by default

Copy link
Member Author

@willfarrell willfarrell Apr 26, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From a code standpoint, yes. From an end-user standpoint, no. Since this middleware is paired with a json schema, having coerceTypes enabled would make requests more forgiving when a body is passed in and enable the ability to parse query strings making them more usable. There are no cases where existing code would fail where they once passed. For example; if someone is using the current defaults their schema would have "type":"string" for all their query string params, in this case, no coercion would happen. coerceTypes is only applied if the types don't match. I added this option to support the use case when you expect an array from the query string, but only one item was passed in.

I feel including this as our default will be doing a service to younger developers, allowing their APIs to be more forgiving out of the gate. However, I do understand why if you'd like to default to something more strict. I'm good either way due to I'm always passing in custom options anyway. Let me know which way you want to go.

The background behind coerceTypes: 'array' can be found here: ajv-validator/ajv#158

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For example; if someone is using the current defaults their schema would have "type":"string" for all their query string params, in this case, no coercion would happen.

Totally makes sense for me. And thanks for adding this, I see how it will be useful in my code already!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vladgolubev Along these lines, you may also be interested in this PR: ajv-validator/ajv-keywords#64
I just had a new keyword added, transform, that allows to you to do some sanitization on strings being passed in. We're drafting up next steps for it here: ajv-validator/ajv-keywords#66

allErrors: true,
useDefaults: true,
$data: true, // required for ajv-keywords
defaultLanguage: 'en'
}

const availableLanguages = Object.keys(ajvLocalize)

/* in ajv-i18n Portuguese is represented as pt-BR */
const languageNormalizationMap = {
'pt': 'pt-BR',
'pt-br': 'pt-BR',
'pt_BR': 'pt-BR',
'pt_br': 'pt-BR'
}

Copy link
Member

@lmammino lmammino Apr 24, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes Portuguese less ambiguous and it will work with all possible variations.
Honestly, I am not sure if it's a good idea to go down the loophole of dealing with this kind of things. So far, looking at ajv-i18n, only Portuguese was the outlier, so I thought it was easy enough to support all the possible variations.

const normalizePreferredLanguage = (lang) => languageNormalizationMap[lang] || lang

module.exports = ({inputSchema, outputSchema, ajvOptions}) => {
const chooseLanguage = ({ preferredLanguage }, defaultLanguage) => {
if (preferredLanguage) {
const lang = normalizePreferredLanguage(preferredLanguage)
if (availableLanguages.includes(lang)) {
return lang
}
}

return defaultLanguage
}

module.exports = ({ inputSchema, outputSchema, ajvOptions }) => {
const options = Object.assign({}, defaults, ajvOptions)
lazyLoadAjv(options)

Expand All @@ -24,14 +55,18 @@ module.exports = ({inputSchema, outputSchema, ajvOptions}) => {

if (!valid) {
const error = new createError.BadRequest('Event object failed validation')
handler.event.headers = Object.assign({}, handler.event.headers)
const language = chooseLanguage(handler.event, options.defaultLanguage)
ajvLocalize[language](validateInput.errors)

error.details = validateInput.errors
throw error
}

return next()
},
after (handler, next) {
if (!outputSchema) {
if (!outputSchema || (!handler.response && handler.error)) {
return next()
}

Expand Down