Repository: fastify/fastify (JavaScript)

Issue:
Shared schema referenced by validator
## 🐛 Bug Report

Now if I set a JSON Schema for validating the input of a route, defining a `$ref` relative to a shared schema, the fastify server doesn't start returning an error:

> { [Error: can't resolve reference http://example.com/ref-to-external-validator.json# from id #]
  message:
   'can\'t resolve reference http://example.com/ref-to-external-validator.json# from id #',
  missingRef: 'http://example.com/ref-to-external-validator.json',
  missingSchema: 'http://example.com/ref-to-external-validator.json' }

## To Reproduce

[Here a GIST with the demo code](https://gist.github.com/Eomm/2e6e01a02e06d34256c767e076b5562f#file-test-validator-ref-id)

## Expected behavior

The validator must consider the schema added via `addSchema` to compile the `body | querystringSchema | paramsSchema | headersSchema`

Paste the results here:

I have tried a brutal solution to apply in `lib\validation.js` that works -for AJV-:
```js
if (context.schema.body) {
    Object.values(schemas.getSchemas()).forEach(_ => globalAjv.addSchema(_))
    context[bodySchema] = compile(context.schema.body)
  }
```

Since the `buildSchemaCompiler` must return a function, I can't find a way to use the `addSchema` function in the `compile` parameter. So I would to return a function that accept the schema as first parameter and the array of other external/support schemas as second param.
This shouldn't have impact to actual usage of the feature

The other idea is to move the building of the schema just before this line:

https://github.com/fastify/fastify/blob/c9f6ba92788c1ef70fb24ab4bb34a5d58f433dde/fastify.js#L639

where I should have already all the shared schemas and I could just add an array parameter with the shared schemas to `buildSchemaCompiler` and the users must only accept or ignore that param.

Let me know what do you think

Thanks

## Your Environment

- *node version*: 10.11
- *fastify version*: >= 2.0.0-rc.6
- *os*: Windows



Discussion:
+1, would you like to send a PR?
Yes, I'm trying the two different solutions ✌



Patch:
diff --git a/docs/Validation-and-Serialization.md b/docs/Validation-and-Serialization.md
index f19d98f8af8..a2984bdbec2 100644
--- a/docs/Validation-and-Serialization.md
+++ b/docs/Validation-and-Serialization.md
@@ -51,6 +51,37 @@ fastify.post('/the/url', { schema }, handler)
 <a name="shared-schema"></a>
 #### Adding a shared schema
 Thanks to the `addSchema` API, you can add multiple schemas to the Fastify instance and then reuse them in multiple parts of your application. As usual, this API is encapsulated.
+
+There are two ways to reuse your shared schemas:
++ **`$ref-way`**: as described in the [standard](https://tools.ietf.org/html/draft-handrews-json-schema-01#section-8),
+you can refer to an external schema. To use it you have to `addSchema` with a valid `$id` absolute URI.
+
+```js
+fastify.addSchema({
+  $id: 'http://example.com/common.json',
+  type: 'object',
+  properties: {
+    hello: { type: 'string' }
+  }
+})
+
+fastify.route({
+  method: 'POST',
+  url: '/',
+  schema: {
+    body: {
+      type: 'array',
+      items: { $ref: 'http://example.com/common.json#/properties/hello' }
+    }
+  },
+  handler: () => {}
+})
+```
+
++ **`replace-way`**: this is a Fastify utility that lets you to substitute some fields with a shared schema.
+To use it you have to `addSchema` with an `$id` having a relative URI fragment which is a simple string that
+applies only to alphanumeric chars `[A-Za-z0-9]`.
+
 ```js
 const fastify = require('fastify')()
 
@@ -313,8 +344,8 @@ in conjuction with the Fastify's shared schema, let you reuse all your schemas e
 | shared schema                     | ✔️ | ✔️ |
 | `$ref` to `$id`                   | ✔ | ✔️ |
 | `$ref` to `/definitions`          | ✔️ | ✔️ |
-| `$ref` to shared schema `$id`          | ❌ | ✔️ |
-| `$ref` to shared schema `/definitions` | ❌ | ✔️ |
+| `$ref` to shared schema `$id`          | ✔ | ✔️ |
+| `$ref` to shared schema `/definitions` | ✔ | ✔️ |
 
 #### Examples
 
diff --git a/fastify.js b/fastify.js
index 866a750017e..5a7448d771e 100644
--- a/fastify.js
+++ b/fastify.js
@@ -212,7 +212,6 @@ function build (options) {
   fastify[kContentTypeParser] = new ContentTypeParser(fastify[kBodyLimit], options.onProtoPoisoning)
 
   fastify.setSchemaCompiler = setSchemaCompiler
-  fastify.setSchemaCompiler(buildSchemaCompiler())
 
   // plugin
   fastify.register = fastify.use
@@ -636,6 +635,11 @@ function build (options) {
       )
 
       try {
+        if (opts.schemaCompiler == null && _fastify._schemaCompiler == null) {
+          const externalSchemas = _fastify[kSchemas].getJsonSchemas({ onlyAbsoluteUri: true })
+          _fastify.setSchemaCompiler(buildSchemaCompiler(externalSchemas))
+        }
+
         buildSchema(context, opts.schemaCompiler || _fastify._schemaCompiler, _fastify[kSchemas])
       } catch (error) {
         done(error)
diff --git a/lib/schemas.js b/lib/schemas.js
index 8a335c80002..9fb885ebd80 100644
--- a/lib/schemas.js
+++ b/lib/schemas.js
@@ -107,16 +107,26 @@ Schemas.prototype.getSchemas = function () {
   return Object.assign({}, this.store)
 }
 
-function buildSchemas (s) {
-  const schema = new Schemas()
-  const store = s.getSchemas()
-  Object.keys(store).forEach(schemaKey => {
-    // if the shared-schema has been used, the $id field has been removed
+Schemas.prototype.getJsonSchemas = function (options) {
+  const store = this.getSchemas()
+  const schemasArray = Object.keys(store).map(schemaKey => {
+    // if the shared-schema "replace-way" has been used, the $id field has been removed
     if (store[schemaKey]['$id'] === undefined) {
       store[schemaKey]['$id'] = schemaKey
     }
-    schema.add(store[schemaKey])
+    return store[schemaKey]
   })
+
+  if (options && options.onlyAbsoluteUri === true) {
+    // the caller wants only the absolute URI (without the shared schema - "replace-way" usage)
+    return schemasArray.filter(_ => !/^\w*$/g.test(_.$id))
+  }
+  return schemasArray
+}
+
+function buildSchemas (s) {
+  const schema = new Schemas()
+  s.getJsonSchemas().forEach(_ => schema.add(_))
   return schema
 }
 
diff --git a/lib/validation.js b/lib/validation.js
index 9ac49b7f3c1..3ef11413961 100644
--- a/lib/validation.js
+++ b/lib/validation.js
@@ -144,7 +144,7 @@ function schemaErrorsText (errors, dataVar) {
   return text.slice(0, -separator.length)
 }
 
-function buildSchemaCompiler () {
+function buildSchemaCompiler (externalSchemas) {
   // This instance of Ajv is private
   // it should not be customized or used
   const ajv = new Ajv({
@@ -154,6 +154,10 @@ function buildSchemaCompiler () {
     allErrors: true
   })
 
+  if (Array.isArray(externalSchemas)) {
+    externalSchemas.forEach(s => ajv.addSchema(s))
+  }
+
   return ajv.compile.bind(ajv)
 }
 


Test patch:
diff --git a/test/shared-schemas.test.js b/test/shared-schemas.test.js
index cdf6c449a1a..7044d78017e 100644
--- a/test/shared-schemas.test.js
+++ b/test/shared-schemas.test.js
@@ -315,6 +315,109 @@ test('Encapsulation isolation for getSchemas', t => {
   })
 })
 
+test('Encapsulation isolation for $ref to shared schema', t => {
+  t.plan(10)
+  const fastify = Fastify()
+
+  const commonSchemaAbsoluteUri = {
+    $id: 'http://example.com/asset.json',
+    type: 'object',
+    definitions: {
+      id: {
+        $id: '#uuid',
+        type: 'string',
+        format: 'uuid'
+      },
+      email: {
+        $id: '#email',
+        type: 'string',
+        format: 'email'
+      }
+    }
+  }
+
+  fastify.register((instance, opts, next) => {
+    instance.addSchema(commonSchemaAbsoluteUri)
+    instance.route({
+      method: 'POST',
+      url: '/id',
+      schema: {
+        body: {
+          type: 'object',
+          properties: { id: { $ref: 'http://example.com/asset.json#uuid' } },
+          required: ['id']
+        }
+      },
+      handler: (req, reply) => { reply.send('id is ok') }
+    })
+    next()
+  })
+
+  fastify.register((instance, opts, next) => {
+    instance.addSchema(commonSchemaAbsoluteUri)
+    instance.route({
+      method: 'POST',
+      url: '/email',
+      schema: {
+        body: {
+          type: 'object',
+          properties: { email: { $ref: 'http://example.com/asset.json#/definitions/email' } },
+          required: ['email']
+        }
+      },
+      handler: (req, reply) => { reply.send('email is ok') }
+    })
+    next()
+  })
+
+  const requestId = { id: '550e8400-e29b-41d4-a716-446655440000' }
+  const requestEmail = { email: 'foo@bar.it' }
+
+  fastify.inject({
+    method: 'POST',
+    url: '/id',
+    payload: requestId
+  }, (err, res) => {
+    t.error(err)
+    t.strictEqual(res.statusCode, 200)
+  })
+  fastify.inject({
+    method: 'POST',
+    url: '/id',
+    payload: requestEmail
+  }, (err, res) => {
+    t.error(err)
+    t.strictEqual(res.statusCode, 400)
+    t.deepEqual(JSON.parse(res.payload), {
+      error: 'Bad Request',
+      message: 'body should have required property \'id\'',
+      statusCode: 400
+    })
+  })
+
+  fastify.inject({
+    method: 'POST',
+    url: '/email',
+    payload: requestEmail
+  }, (err, res) => {
+    t.error(err)
+    t.strictEqual(res.statusCode, 200)
+  })
+  fastify.inject({
+    method: 'POST',
+    url: '/email',
+    payload: requestId
+  }, (err, res) => {
+    t.error(err)
+    t.strictEqual(res.statusCode, 400)
+    t.deepEqual(JSON.parse(res.payload), {
+      error: 'Bad Request',
+      message: 'body should have required property \'email\'',
+      statusCode: 400
+    })
+  })
+})
+
 test('JSON Schema validation keywords', t => {
   t.plan(2)
   const fastify = Fastify()
@@ -742,11 +845,11 @@ test('Get schema anyway should not add `properties` if anyOf is present', t => {
   fastify.ready(t.error)
 })
 
-test('Shared schema should be pass to serializer ($ref to shared schema /definitions)', t => {
+test('Shared schema should be pass to serializer and validator ($ref to shared schema /definitions)', t => {
   t.plan(2)
   const fastify = Fastify()
 
-  const schemaAsset = {
+  fastify.addSchema({
     $id: 'http://example.com/asset.json',
     $schema: 'http://json-schema.org/draft-07/schema#',
     title: 'Physical Asset',
@@ -774,9 +877,9 @@ test('Shared schema should be pass to serializer ($ref to shared schema /definit
         format: 'email'
       }
     }
-  }
+  })
 
-  const schemaPoint = {
+  fastify.addSchema({
     $id: 'http://example.com/point.json',
     $schema: 'http://json-schema.org/draft-07/schema#',
     title: 'Longitude and Latitude Values',
@@ -802,9 +905,9 @@ test('Shared schema should be pass to serializer ($ref to shared schema /definit
         type: 'number'
       }
     }
-  }
+  })
 
-  const schemaResponse = {
+  const schemaLocations = {
     $id: 'http://example.com/locations.json',
     $schema: 'http://json-schema.org/draft-07/schema#',
     title: 'List of Asset locations',
@@ -813,36 +916,35 @@ test('Shared schema should be pass to serializer ($ref to shared schema /definit
     default: []
   }
 
-  fastify.addSchema(schemaAsset)
-  fastify.addSchema(schemaPoint)
-
-  const response = [
-    { id: 'id1', model: 'mod', location: { latitude: 10, longitude: 10, email: 'foo@bar.it' } },
-    { id: 'id2', model: 'mod', location: { latitude: 10, longitude: 10, email: 'foo@bar.it' } }
+  const locations = [
+    { id: '550e8400-e29b-41d4-a716-446655440000', model: 'mod', location: { latitude: 10, longitude: 10, email: 'foo@bar.it' } },
+    { id: '550e8400-e29b-41d4-a716-446655440000', model: 'mod', location: { latitude: 10, longitude: 10, email: 'foo@bar.it' } }
   ]
-  fastify.get('/', {
+  fastify.post('/', {
     schema: {
-      response: { 200: schemaResponse }
+      body: schemaLocations,
+      response: { 200: schemaLocations }
     }
   }, (req, reply) => {
-    reply.send(response.map(i => Object.assign({ serializer: 'remove me' }, i)))
+    reply.send(locations.map(i => Object.assign({ serializer: 'remove me' }, i)))
   })
 
   fastify.inject({
-    method: 'GET',
-    url: '/'
+    method: 'POST',
+    url: '/',
+    payload: locations
   }, (err, res) => {
     t.error(err)
-    response.forEach(_ => delete _.remove)
-    t.deepEqual(JSON.parse(res.payload), response)
+    locations.forEach(_ => delete _.remove)
+    t.deepEqual(JSON.parse(res.payload), locations)
   })
 })
 
-test('Shared schema should be pass to serializer ($ref to shared schema $id)', t => {
+test('Shared schema should be pass to serializer and validator ($ref to shared schema $id)', t => {
   t.plan(2)
   const fastify = Fastify()
 
-  const schemaAsset = {
+  fastify.addSchema({
     $id: 'http://example.com/asset.json',
     $schema: 'http://json-schema.org/draft-07/schema#',
     title: 'Physical Asset',
@@ -870,9 +972,9 @@ test('Shared schema should be pass to serializer ($ref to shared schema $id)', t
         format: 'email'
       }
     }
-  }
+  })
 
-  const schemaPoint = {
+  fastify.addSchema({
     $id: 'http://example.com/point.json',
     $schema: 'http://json-schema.org/draft-07/schema#',
     title: 'Longitude and Latitude Values',
@@ -898,9 +1000,9 @@ test('Shared schema should be pass to serializer ($ref to shared schema $id)', t
         type: 'number'
       }
     }
-  }
+  })
 
-  const schemaResponse = {
+  const schemaLocations = {
     $id: 'http://example.com/locations.json',
     $schema: 'http://json-schema.org/draft-07/schema#',
     title: 'List of Asset locations',
@@ -909,29 +1011,57 @@ test('Shared schema should be pass to serializer ($ref to shared schema $id)', t
     default: []
   }
 
-  fastify.addSchema(schemaAsset)
-  fastify.addSchema(schemaPoint)
-
-  const response = [
-    { id: 'id1', model: 'mod', location: { latitude: 10, longitude: 10, email: 'foo@bar.it' } },
-    { id: 'id2', model: 'mod', location: { latitude: 10, longitude: 10, email: 'foo@bar.it' } }
+  const locations = [
+    { id: '550e8400-e29b-41d4-a716-446655440000', model: 'mod', location: { latitude: 10, longitude: 10, email: 'foo@bar.it' } },
+    { id: '550e8400-e29b-41d4-a716-446655440000', model: 'mod', location: { latitude: 10, longitude: 10, email: 'foo@bar.it' } }
   ]
-  fastify.get('/', {
+
+  fastify.post('/', {
     schema: {
-      response: { 200: schemaResponse }
+      body: schemaLocations,
+      response: { 200: schemaLocations }
     }
   }, (req, reply) => {
-    reply.send(response.map(i => Object.assign({ serializer: 'remove me' }, i)))
+    reply.send(locations.map(i => Object.assign({ serializer: 'remove me' }, i)))
   })
 
   fastify.inject({
-    method: 'GET',
-    url: '/'
+    method: 'POST',
+    url: '/',
+    payload: locations
   }, (err, res) => {
     t.error(err)
-    response.forEach(_ => delete _.remove)
-    t.deepEqual(JSON.parse(res.payload), response)
+    locations.forEach(_ => delete _.remove)
+    t.deepEqual(JSON.parse(res.payload), locations)
+  })
+})
+
+test('Use shared schema and $ref', t => {
+  t.plan(1)
+  const fastify = Fastify()
+
+  fastify.addSchema({
+    $id: 'http://example.com/ref-to-external-validator.json',
+    type: 'object',
+    properties: {
+      hello: { type: 'string' }
+    }
   })
+
+  const body = {
+    type: 'array',
+    items: { $ref: 'http://example.com/ref-to-external-validator.json#' },
+    default: []
+  }
+
+  fastify.route({
+    method: 'POST',
+    url: '/',
+    schema: { body },
+    handler: (_, r) => { r.send('ok') }
+  })
+
+  fastify.ready(t.error)
 })
 
 test('Use shared schema and $ref to /definitions', t => {