{"action":"created","issue":{"url":"https://api.github.com/repos/hapijs/joi/issues/577","labels_url":"https://api.github.com/repos/hapijs/joi/issues/577/labels{/name}","comments_url":"https://api.github.com/repos/hapijs/joi/issues/577/comments","events_url":"https://api.github.com/repos/hapijs/joi/issues/577/events","html_url":"https://github.com/hapijs/joi/issues/577","id":59351629,"number":577,"title":"RFC: Joi plugins/extensions/custom functions","user":{"login":"Marsup","id":796194,"avatar_url":"https://avatars.githubusercontent.com/u/796194?v=3","gravatar_id":"","url":"https://api.github.com/users/Marsup","html_url":"https://github.com/Marsup","followers_url":"https://api.github.com/users/Marsup/followers","following_url":"https://api.github.com/users/Marsup/following{/other_user}","gists_url":"https://api.github.com/users/Marsup/gists{/gist_id}","starred_url":"https://api.github.com/users/Marsup/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/Marsup/subscriptions","organizations_url":"https://api.github.com/users/Marsup/orgs","repos_url":"https://api.github.com/users/Marsup/repos","events_url":"https://api.github.com/users/Marsup/events{/privacy}","received_events_url":"https://api.github.com/users/Marsup/received_events","type":"User","site_admin":false},"labels":[{"url":"https://api.github.com/repos/hapijs/joi/labels/discussion","name":"discussion","color":"d4c5f9"},{"url":"https://api.github.com/repos/hapijs/joi/labels/enhancement","name":"enhancement","color":"207de5"},{"url":"https://api.github.com/repos/hapijs/joi/labels/help%20wanted","name":"help wanted","color":"d4c5f9"}],"state":"open","locked":false,"assignee":{"login":"Marsup","id":796194,"avatar_url":"https://avatars.githubusercontent.com/u/796194?v=3","gravatar_id":"","url":"https://api.github.com/users/Marsup","html_url":"https://github.com/Marsup","followers_url":"https://api.github.com/users/Marsup/followers","following_url":"https://api.github.com/users/Marsup/following{/other_user}","gists_url":"https://api.github.com/users/Marsup/gists{/gist_id}","starred_url":"https://api.github.com/users/Marsup/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/Marsup/subscriptions","organizations_url":"https://api.github.com/users/Marsup/orgs","repos_url":"https://api.github.com/users/Marsup/repos","events_url":"https://api.github.com/users/Marsup/events{/privacy}","received_events_url":"https://api.github.com/users/Marsup/received_events","type":"User","site_admin":false},"milestone":{"url":"https://api.github.com/repos/hapijs/joi/milestones/30","html_url":"https://github.com/hapijs/joi/milestones/7.0.0","labels_url":"https://api.github.com/repos/hapijs/joi/milestones/30/labels","id":1000113,"number":30,"title":"7.0.0","description":null,"creator":{"login":"Marsup","id":796194,"avatar_url":"https://avatars.githubusercontent.com/u/796194?v=3","gravatar_id":"","url":"https://api.github.com/users/Marsup","html_url":"https://github.com/Marsup","followers_url":"https://api.github.com/users/Marsup/followers","following_url":"https://api.github.com/users/Marsup/following{/other_user}","gists_url":"https://api.github.com/users/Marsup/gists{/gist_id}","starred_url":"https://api.github.com/users/Marsup/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/Marsup/subscriptions","organizations_url":"https://api.github.com/users/Marsup/orgs","repos_url":"https://api.github.com/users/Marsup/repos","events_url":"https://api.github.com/users/Marsup/events{/privacy}","received_events_url":"https://api.github.com/users/Marsup/received_events","type":"User","site_admin":false},"open_issues":1,"closed_issues":0,"state":"open","created_at":"2015-02-28T18:09:32Z","updated_at":"2015-02-28T18:16:30Z","due_on":null,"closed_at":null},"comments":13,"created_at":"2015-02-28T18:16:30Z","updated_at":"2015-03-01T16:09:28Z","closed_at":null,"body":"# Joi extensibility proposal\r\n\r\nFollowing the many demands of Joi custom functions, here's my proposal to achieve this in a sane way. I want to gather as many feedback as possible so we don't miss any use case that would be important, so I'll leave this open for a while.\r\n\r\n## `Joi.addType(options)`\r\n\r\n### Description\r\n\r\nAllows creation of new types that will be available as `Joi.<type>`.\r\n\r\n### Parameters\r\n\r\n* `options`\r\n  * `name`: Name of the new type.\r\n  * `base`: Optional base type to derive from. Defaults to `Any`.\r\n  * `language`:  An associative object that defines new common language strings, keys will be prefixed by the type (ie. `foo` -> `<type>.foo`).\r\n  * `convert`: Optional base conversion function to cast your initial value before any rule has a chance to use it. It should throw if the conversion cannot be achieved.\r\n\r\n### Example\r\n\r\n```js\r\nJoi.addType({\r\n    name: 'nonEmptyString',\r\n    base: Joi.string().min(0),\r\n    language: {\r\n      base: 'must cast to a non empty string'\r\n    },\r\n    convert: function (value) {\r\n      return value; // Nothing to do here, but we could\r\n    }\r\n});\r\n\r\nvar schema = Joi.nonEmptyString().required();\r\n```\r\n\r\n---\r\n\r\n## `Joi.<type>.extend(options)`\r\n\r\n### Description\r\n\r\nCalling this modifies the methods available on the used type (ie. its prototype).\r\n\r\nI don't see any advantage to allowing users to unregister methods, so this possibility will **not** be provided.\r\n\r\nThis function returns the same underlying type to allow chaining of several definitions.\r\n\r\n**Any attempt to override an existing method or language will throw an Error.**\r\n\r\n### Parameters\r\n\r\n* `options`\r\n\t* `name`: Name of the method to add, it has to be a valid identifier.\r\n\t* `description`: An optional string that will add a description information about that rule on calls to `describe()`.\r\n\t* `parameters`: A Joi description of what the parameters have to be, only those will be allowed.\r\n\t* `language`: An associative object that defines new rule specific language strings, keys will prefixed by the type (ie. `foo` -> `<type>.foo`).\r\n\t* `validate`: A validation function that takes as parameters :\r\n\t\t* `params`: Parameters provided when using the method.\r\n\t\t* `value`: The value that is being validated.\r\n\t\t* `state`: The current state of validation.\r\n\t\t* `options`: The validation options.\r\n\t* `describe`: An optional function that replaces the `describe`d object of that particular rule, it takes as parameters :\r\n\t\t* `params`: Parameters provided when using the method.\r\n\r\n#### Notes\r\n\r\n* Parameters :\r\n\t* Rely on keys ordering, which should be fine in most environments, there may be corner cases like [this one](https://code.google.com/p/v8/issues/detail?id=164) but it's highly unlikely to happen.\r\n\t* Must each be a valid Joi schema.\r\n\t* Are done in such a way that infinite params is not possible, is it that bad ? We can always accept arrays which might be infinite.\r\n\t* That need complexity on their definition (many optional parameters, different ordering and whatnot) should provide this through a single parameter that's an object containing every requirements, I won't allow custom parsing of arguments.\r\n* The `validate` function :\r\n\t* Has access to the same `this` as any other method.\r\n\t* Can return :\r\n\t\t* `null` : the test passed.\r\n\t\t* An `Error` : the test failed.\r\n\t\t* A `Promise` : this is the only acceptable way to handle asynchronicity. Obviously, any attempt to use that kind of validation in a synchronous manner (ie. `validate` without callback, and `assert`) **will result in an error**. The promise, as its synchronous equivalent, must reject with an `Error` or resolve with `null` or a value.\r\n\t\t* `undefined` is considered an error, in case you have forgotten to return something.\r\n\t\t* Anything else : the value will be re-used in the following tests and the final result, unless of course `.raw()` has been called or `convert` is false. This is a way to achieve sanitization/transformation of values. Since it is internal stuff and doesn't have any effect on the external contract, it will not be `describe`d.\r\n* The `describe` function :\r\n\t* Is synchronous, I don't see any use case for asynchronicity on this.\r\n* These elements should suffice in describing the use of the function, both in terms of what it does and the parameters it took.\r\n\r\n#### Implementation concerns\r\n\r\n* Do you prefer we expose the `Errors` object to allow users to create their own or rather a minimal abstraction of it ?\r\n* The `params` can't be protected unless we accept the cost of a full clone, which might be huge if for example a database connection were to be provided. You'll have to take care of cloning yourself if you need to tamper with the original values.\r\n* Concerning the `value`, we have several choices to avoid tampering :\r\n\t* Freezing objects would be safer but according to micro benchmarks I did it has a 40% performance cost in node 0.10, 20% in 0.11. This will additionally imply the cost of a deep clone at the very end of the validation to provide an unfrozen value.\r\n\t* Providing a deep clone on each step\r\n\t* Not doing anything and trust the developer\r\n* Should we protect with domains the execution of the `validate` function ? It seems overkill. Simply try/catch synchronous errors and leave the rest to the promise ?\r\n\r\n### Examples\r\n\r\nLet's re-implement `string().min()` as a basic example :\r\n\r\n```js\r\nJoi.string.extend({\r\n  name: 'min',\r\n  description: 'Minimum length',\r\n  parameters: {\r\n    limit: Joi.number().min(0).required(),\r\n    encoding: Joi.string()\r\n  },\r\n  language: {\r\n    min: 'length must be at least {{limit}} characters long'\r\n  },\r\n  validate: function (params, value, state, options) {\r\n\r\n    var limit = params.limit;\r\n    var encoding = params.encoding;\r\n\r\n    var length = encoding ? Buffer.byteLength(value, encoding) : value.length;\r\n    if (length >= limit) {\r\n      return null;\r\n    }\r\n\r\n    return Errors.create('string.min', { limit: limit }, state, options);\r\n  }\r\n});\r\n```\r\n\r\nHere's another example with `number().precision()` transforming its value in case of conversion :\r\n```js\r\nvar precisionRx = /* regexp for precision */;\r\nJoi.number.extend({\r\n  name: 'precision',\r\n  description: 'Maximum number of decimal places',\r\n  parameters: {\r\n    limit: Joi.number().required()\r\n  },\r\n  language: {\r\n    precision: 'must have no more than {{limit}} decimal places'\r\n  },\r\n  validate: function (params, value, state, options) {\r\n\r\n    if (options.convert) {\r\n      return value.toFixed(params.limit);\r\n    }\r\n\r\n    var places = value.toString().match(precisionRx);\r\n    var decimals = Math.max((places[1] ? places[1].length : 0) - (places[2] ? parseInt(places[2], 10) : 0), 0);\r\n    \r\n    if (decimals <= params.limit) {\r\n      return null;\r\n    }\r\n    return Errors.create('number.precision', {limit: limit}, state, options);\r\n  }\r\n});\r\n```\r\n\r\nNow let's see a more complex asynchronous example based on a mongodb role search :\r\n```js\r\nvar user = Joi.addType({\r\n    name: 'user',\r\n    base: Joi.string().min(1).required(), // The username will always be required\r\n});\r\n\r\nuser.extend({\r\n  name: 'hasRole',\r\n  description: 'check if a user has a role',\r\n  parameters: {\r\n    role: Joi.string().required(),\r\n    db: Joi.object().required()\r\n  },\r\n  language: {\r\n    database: '!!failed to verify the user because of a database error: {{err}}',\r\n    role: '!!user {{user}} does not match role {{role}}'\r\n  },\r\n  validate: function (params, value, state, options) {\r\n    return new Promise(function (resolve, reject) {\r\n      params.db.collection('users').count({ user: value, role: params.role }, function (err, count) {\r\n        if (err) {\r\n          return reject(Errors.create('user.database', { err: err }, state, options));\r\n        }\r\n        \r\n        // That's a simplification because I don't want to deal with multiple matches in this example\r\n        if (count > 0) {\r\n          return resolve(null);\r\n        }\r\n        \r\n        reject(Errors.create('user.role', { user: value, role: params.role }, state, options));\r\n      })\r\n    });\r\n  },\r\n  describe: function (params) {\r\n    // We don't care about the database in the description, only the role matters\r\n    return {\r\n      role: params.role\r\n    };\r\n  }\r\n});\r\n\r\n// Now let's imagine this is in another file\r\nvar schema = Joi.user().hasRole('admin', db);\r\nschema.validate('myUsername', function (err, value) {});\r\n\r\n// If you were to call this, it would fail though, because it contains something asynchronous\r\nschema.validate('myUsername');\r\nJoi.assert('myUsername', schema);\r\n\r\nschema.describe();\r\n// should approximately result in :\r\n// {\r\n//   type: 'user',\r\n//   rules: [\r\n//     {\r\n//       name: 'hasRule',\r\n//       description : 'check if a user has a role',\r\n//       arg: {\r\n//         role: 'admin'\r\n//       }\r\n//     }\r\n//   ]\r\n// }\r\n```\r\n"},"comment":{"url":"https://api.github.com/repos/hapijs/joi/issues/comments/76604538","html_url":"https://github.com/hapijs/joi/issues/577#issuecomment-76604538","issue_url":"https://api.github.com/repos/hapijs/joi/issues/577","id":76604538,"user":{"login":"totherik","id":412006,"avatar_url":"https://avatars.githubusercontent.com/u/412006?v=3","gravatar_id":"","url":"https://api.github.com/users/totherik","html_url":"https://github.com/totherik","followers_url":"https://api.github.com/users/totherik/followers","following_url":"https://api.github.com/users/totherik/following{/other_user}","gists_url":"https://api.github.com/users/totherik/gists{/gist_id}","starred_url":"https://api.github.com/users/totherik/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/totherik/subscriptions","organizations_url":"https://api.github.com/users/totherik/orgs","repos_url":"https://api.github.com/users/totherik/repos","events_url":"https://api.github.com/users/totherik/events{/privacy}","received_events_url":"https://api.github.com/users/totherik/received_events","type":"User","site_admin":false},"created_at":"2015-03-01T16:09:28Z","updated_at":"2015-03-01T16:09:28Z","body":"Thanks for clarifying my comments @myndzi. (Weekend away with the fam so trying to stay offline :) )\r\n\r\nSo with the pursuit of types-as-npm-modules, is it reasonable to expect that a single application/module may need to depend on incompatible versions of the same npm module type definition? If so, npm can't be used to manage those dependencies. (I'm thinking major revisions of types that aren't required to be backward compatible according to semver.)\r\n\r\nI don't know all use-cases, but I have to imagine this could be an issue. "}}