Skip to content

Commit

Permalink
Support multiple languages. Closes #1866
Browse files Browse the repository at this point in the history
  • Loading branch information
hueniverse committed Jun 18, 2019
1 parent a5ceb30 commit f106b49
Show file tree
Hide file tree
Showing 9 changed files with 242 additions and 59 deletions.
12 changes: 8 additions & 4 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,13 +305,17 @@ Validates a value using the given schema and options where:
- `'utc'` - UTC date time string.
- `escapeHtml` - when `true`, error message templates will escape special characters to HTML
entities, for security purposes. Defaults to `false`.
- `language` - the prefered language code for error messages. The value is matched against keys
are the root of the `messages` object, and then the error code as a child key of that.
- `wrapArrays` - if `true`, array values in error messages are wrapped in `[]`. Defaults to `true`.
- `messages` - overrides individual error messages. Defaults to no override (`{}`). Messages use
the same rules as [templates](#template-syntax). Variables in double braces `{{var}}` are HTML
escaped if the option `errors.escapeHtml` is set to `true`.
- `noDefaults` - when `true`, do not apply default values. Defaults to `false`.
- `nonEnumerables` - when `true`, inputs are shallow cloned to include non-enumerables properties. Defaults to `false`.
- `presence` - sets the default presence requirements. Supported modes: `'optional'`, `'required'`, and `'forbidden'`.
Defaults to `'optional'`.
- `nonEnumerables` - when `true`, inputs are shallow cloned to include non-enumerables properties.
Defaults to `false`.
- `presence` - sets the default presence requirements. Supported modes: `'optional'`, `'required'`,
and `'forbidden'`. Defaults to `'optional'`.
- `skipFunctions` - when `true`, ignores unknown keys with a function value. Defaults to `false`.
- `stripUnknown` - remove unknown elements from objects and arrays. Defaults to `false`.
- when an `object` :
Expand Down Expand Up @@ -1063,7 +1067,7 @@ Applies a set of rule options to the current ruleset or last rule added where:
`Joi.number().min(1).rule({ keep: true }).min(2)` will keep both `min()` rules instead of the later
rule overriding the first. Defaults to `false`.
- `message` - a single message string or a messages object where each key is an error code and
corresponding message string as value. The object is the same as the one used as an option in
corresponding message string as value. The object is the same as the `messages` used as an option in
[`Joi.validate(value, schema, options, callback)`](#validatevalue-schema-options-callback).
The strings can be plain messages or a message template.

Expand Down
6 changes: 2 additions & 4 deletions lib/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ exports.defaults = {
errors: {
dateFormat: 'iso',
escapeHtml: false,
language: null,
wrapArrays: true
},
messages: {},
Expand Down Expand Up @@ -42,10 +43,7 @@ exports.preferences = function (target, source) {
}

const merged = Hoek.applyToDefaults(target || {}, source);
if (merged[exports.symbols.prefs]) {
delete merged[exports.symbols.prefs];
}

delete merged[exports.symbols.prefs];
return merged;
};

Expand Down
130 changes: 97 additions & 33 deletions lib/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,29 +28,52 @@ exports.Report = class {
this.template = null;

this.local = local || {};
this.local.label = state.flags.label || internals.label(this.path) || prefs.messages && prefs.messages.root || Messages.errors.root;

if (value !== undefined &&
this.local.label = this.state.flags.label ||
internals.label(this.path) ||
internals.language(this.prefs.messages, this.prefs.errors.language, 'root') ||
Messages.errors.root;

if (this.value !== undefined &&
!this.local.hasOwnProperty('value')) {

this.local.value = value;
this.local.value = this.value;
}

if (this.path.length) {
this.local.key = this.path[this.path.length - 1];
}
}

_setTemplate(template) {

this.template = template;

if (!this.state.flags.label &&
this.path.length === 0) {

const localized = internals.language(this.template, this.prefs.errors.language, 'root');
if (localized) {
this.local.label = localized;
}
}
}

toString() {

if (this.message) {
return this.message;
}

const localized = this.prefs.messages;
let template = this.template || localized[this.code] || internals.templates[this.code];
const language = this.prefs.errors.language;
const code = this.code;

let template = internals.language(this.template, this.prefs.errors.language, this.code) ||
internals.language(this.prefs.messages, language, code) ||
internals.templates[code];

if (template === undefined) {
return `Error code "${this.code}" is not defined, your custom type is missing the correct messages definition`;
return `Error code "${code}" is not defined, your custom type is missing the correct messages definition`;
}

if (typeof template === 'string') {
Expand Down Expand Up @@ -83,6 +106,27 @@ internals.label = function (path) {
};


internals.language = function (messages, lang, code) {

if (!messages) {
return;
}

if (Template.isTemplate(messages)) {
return code !== 'root' ? messages : null;
}

if (lang &&
messages[lang] &&
messages[lang][code] !== undefined) {

return messages[lang][code];
}

return messages[code];
};


exports.process = function (errors, original) {

if (!errors) {
Expand Down Expand Up @@ -326,47 +370,67 @@ internals.serializer = function () {

exports.messages = function (options) {

if (!options.message) {
const messages = options.message;
if (!messages) {
return options;
}

if (typeof options.message === 'string') {
const template = new Template(options.message);
if (template.isDynamic()) {
options = Object.assign({}, options); // Shallow cloned
options.template = template;
delete options.message;
}
// Single value string ('plain error message', 'template {error} message')

if (typeof messages === 'string') {
return Object.assign({}, options, { message: new Template(messages) }); // Shallow cloned
}

// Single value template

if (Template.isTemplate(messages)) {
return options;
}

const sorted = { message: {}, template: {} };
for (const code in options.message) {
const message = options.message[code];
const template = new Template(message);
if (template.isDynamic()) {
sorted.template[code] = template;
// By error code { 'number.min': <string | template> }

Hoek.assert(typeof messages === 'object' && !Array.isArray(messages), 'Invalid message options');

const templates = {};
for (let code in messages) {
const message = messages[code];

if (code === 'root' ||
Template.isTemplate(message)) {

templates[code] = message;
continue;
}
else {
sorted.message[code] = message;

if (typeof message === 'string') {
templates[code] = new Template(message);
continue;
}
}

if (!Object.keys(sorted.template).length) {
return options;
}
// By language { english: { 'number.min': <string | template> } }

options = Object.assign({}, options); // Shallow clones
options.template = sorted.template;
Hoek.assert(typeof message === 'object' && !Array.isArray(message), 'Invalid message for', code);

if (Object.keys(sorted.message).length) {
options.message = sorted.message;
}
else {
delete options.message;
const language = code;
templates[language] = {};

for (code in message) {
const localized = message[code];

if (code === 'root' ||
Template.isTemplate(localized)) {

templates[language][code] = localized;
continue;
}

Hoek.assert(typeof localized === 'string', 'Invalid message for', code, 'in', language);
templates[language][code] = new Template(localized);
}
}

options = Object.assign({}, options); // Shallow cloned
options.message = templates;
return options;
};

Expand Down
3 changes: 3 additions & 0 deletions lib/schemas.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

const Joi = require('./index');

const Messages = require('./messages');


const internals = {};

Expand All @@ -14,6 +16,7 @@ exports.preferences = Joi.object({
errors: {
dateFormat: Joi.string().only('date', 'iso', 'string', 'time', 'utc'),
escapeHtml: Joi.boolean(),
language: Joi.string().invalid(...Object.keys(Messages.errors)),
wrapArrays: Joi.boolean()
},
messages: Joi.object(),
Expand Down
7 changes: 6 additions & 1 deletion lib/template.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ module.exports = exports = internals.Template = class {

render(value, state, prefs, local, options = {}) {

if (!this._template) {
if (!this.isDynamic()) {
return this.rendered;
}

Expand All @@ -112,6 +112,11 @@ module.exports = exports = internals.Template = class {
return { template: this.source, options: this._settings };
}

static isTemplate(message) {

return message instanceof internals.Template;
}

isDynamic() {

return !!this._template;
Expand Down
2 changes: 1 addition & 1 deletion lib/types/array.js
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@ internals.Array.prototype._rules = {
const localState = schema._state(wasArray ? i : key, path, [value, ...state.ancestors]);

for (const exclusion of schema._inner.exclusions) {
if (!exclusion._match(item, localState, {})) { // Not passing prefs to use defaults
if (!exclusion._match(item, localState, prefs)) {
continue;
}

Expand Down
17 changes: 2 additions & 15 deletions lib/validator.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ const Hoek = require('@hapi/hoek');
const Common = require('./common');
const Errors = require('./errors');
const Ref = require('./ref');
const Template = require('./template');


const internals = {};
Expand Down Expand Up @@ -204,22 +203,10 @@ exports.validate = function (value, schema, state, prefs, reference) {
};


internals.error = function (report, { message, template }) {
internals.error = function (report, { message }) {

if (message) {
message = typeof message === 'string' ? message : message[report.code];
if (message) {
report.message = message;
return report;
}
}

if (template) {
template = template instanceof Template ? template : template[report.code];
if (template) {
report.template = template;
return report;
}
report._setTemplate(message);
}

return report;
Expand Down
22 changes: 22 additions & 0 deletions test/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,28 @@ describe('errors', () => {
]);
});

it('supports language preference', () => {

const schema = Joi.number().min(10);

const messages = {
english: {
root: 'value',
'number.min': '{#label} too small'
},
latin: {
root: 'valorem',
'number.min': Joi.template('{%label} angustus', { prefix: { local: '%' } })
},
empty: {}
};

expect(schema.validate(1, { messages, errors: { language: 'english' } }).error).to.be.an.error('value too small');
expect(schema.validate(1, { messages, errors: { language: 'latin' } }).error).to.be.an.error('valorem angustus');
expect(schema.validate(1, { messages, errors: { language: 'unknown' } }).error).to.be.an.error('"value" must be larger than or equal to 10');
expect(schema.validate(1, { messages, errors: { language: 'empty' } }).error).to.be.an.error('"value" must be larger than or equal to 10');
});

it('does not prefix with key when messages uses context.key', async () => {

const schema = Joi.valid('sad').prefs({ messages: { 'any.allowOnly': 'my hero "{{#label}}" is not {{#valids}}' } });
Expand Down
Loading

0 comments on commit f106b49

Please sign in to comment.