@iopa/schema-router
About
@iopa/schema-router
is a compiled schema validator and router for the IOPA framework
It validates JSON Schema and JSON Type Definitions for both inbound requests and outbound responses
Usage
Routing
Include whereever you would otherwise use @iopa/router
See @iopa/router
for all routing documentation
Installation
import { RouterApp } from 'iopa'
import SchemaRouter, { type ISchemaAppOptions } from '@iopa/schema-router'
const app: ISchemaApp = new RouterApp()
//
// . . . .
//
app.use<ISchemaAppOptions>(SchemaRouter, 'Schema Router', {
jsonShorthand: false
})
app.get(
'/api/helloworld-us-only',
async (context) => {
return 'Hello World'
},
{
schema: {
headers: {
type: 'object',
properties: { 'cf-ipcountry': { type: 'string', pattern: '^US$' } },
required: ['cf-ipcountry']
}
}
}
)
Validation and Serialization
Iopa Schema Router uses a schema-based approach, and even if it is not mandatory we recommend using JSON Schema to validate your routes and serialize your outputs. Internally, Iopa Schema Router compiles the schema into a highly performant function.
Validation will only be attempted if the content type is application-json
, as
described in the documentation for the content type
parser.
All the examples in this section are using the JSON Schema Draft 7 specification.
⚠ Security NoticeTreat the schema definition as application code. Validation and serialization features dynamically evaluate code with
new Function()
, which is not safe to use with user-provided schemas. See Ajv and fast-json-stringify for more details.Moreover, the
$async
Ajv feature should not be used as part of the first validation strategy. This option is used to access Databases and reading them during the validation process may lead to Denial of Service Attacks to your application.
Core concepts
The validation and the serialization tasks are processed by two different, and customizable, actors:
- Ajv v8 for the validation of a request
- fast-json-stringify for the serialization of a response's body
These two separate entities share only the JSON schemas added to Iopa
app instance through .addSchema(schema)
.
Adding a shared schema
Thanks to the addSchema
API added to the Iopa app instance, you can add multiple schemas
to the Iopa Schema Router and then reuse them in multiple parts of your application.
The shared schemas can be reused through the JSON Schema
$ref
keyword. Here is an overview of how references work:
-
myField: { $ref: '#foo'}
will search for field with$id: '#foo'
inside the current schema -
myField: { $ref: '#/definitions/foo'}
will search for fielddefinitions.foo
inside the current schema -
myField: { $ref: 'http://url.com/sh.json#'}
will search for a shared schema added with$id: 'http://url.com/sh.json'
-
myField: { $ref: 'http://url.com/sh.json#/definitions/foo'}
will search for a shared schema added with$id: 'http://url.com/sh.json'
and will use the fielddefinitions.foo
-
myField: { $ref: 'http://url.com/sh.json#foo'}
will search for a shared schema added with$id: 'http://url.com/sh.json'
and it will look inside of it for object with$id: '#foo'
Simple usage:
app.addSchema({
$id: 'http://example.com/',
type: 'object',
properties: {
hello: { type: 'string' }
}
})
app.post('/',
myHandlerFunction,
{
schema: {
body: {
type: 'array',
items: { $ref: 'http://example.com#/properties/hello' }
}
}
})
$ref
as root reference:
app.addSchema({
$id: 'commonSchema',
type: 'object',
properties: {
hello: { type: 'string' }
}
})
a[[]].post('/', {
myHandlerFunction,
schema: {
body: { $ref: 'commonSchema#' },
headers: { $ref: 'commonSchema#' }
}
})
Retrieving the shared schemas
If the validator and the serializer are customized, the .addSchema
method will
not be useful since the actors are no longer controlled by Iopa Schema Router. To access
the schemas added to the Iopa Schema Router instance, you can simply use .getSchemas()
available on the urn:io.iopa.schema:controller
capability:
app.capability('urn:io.iopa.schema:controller').addSchema({
$id: 'schemaId',
type: 'object',
properties: {
hello: { type: 'string' }
}
})
const mySchemas = app.capability('urn:io.iopa.schema:controller').getSchemas()
const mySchema = app.capability('urn:io.iopa.schema:controller').getSchema('schemaId')
The function getSchemas
returns the shared
schemas available in the selected scope:
app.addSchema({ $id: 'one', my: 'hello' })
// will return only `one` schema
app.get('/', (request, reply) => { reply.send(app.capability('urn:io.iopa.schema:controller').getSchemas()) })
Validation
The route validation internally relies upon Ajv v8 which is a high-performance JSON Schema validator. Validating the input is very easy: just add the fields that you need inside the route schema, and you are done!
The supported validations are:
-
body
: validates the body of the request if it is a POST, PUT, or PATCH method. -
querystring
orquery
: validates the query string. -
params
: validates the route params. -
headers
: validates the request headers.
All the validations can be a complete JSON Schema object (with a type
property
of 'object'
and a 'properties'
object containing parameters) or a simpler
variation in which the type
and properties
attributes are forgone and the
parameters are listed at the top level (see the example below).
ℹ If you need to use the latest version of Ajv (v8) you should read how to do it in theschemaController
section.
Example:
const bodyJsonSchema = {
type: 'object',
required: ['requiredKey'],
properties: {
someKey: { type: 'string' },
someOtherKey: { type: 'number' },
requiredKey: {
type: 'array',
maxItems: 3,
items: { type: 'integer' }
},
nullableKey: { type: ['number', 'null'] }, // or { type: 'number', nullable: true }
multipleTypesKey: { type: ['boolean', 'number'] },
multipleRestrictedTypesKey: {
oneOf: [
{ type: 'string', maxLength: 5 },
{ type: 'number', minimum: 10 }
]
},
enumKey: {
type: 'string',
enum: ['John', 'Foo']
},
notTypeKey: {
not: { type: 'array' }
}
}
}
const queryStringJsonSchema = {
type: 'object',
properties: {
name: { type: 'string' },
excitement: { type: 'integer' }
}
}
const paramsJsonSchema = {
type: 'object',
properties: {
par1: { type: 'string' },
par2: { type: 'number' }
}
}
const headersJsonSchema = {
type: 'object',
properties: {
'x-foo': { type: 'string' }
},
required: ['x-foo']
}
const schema = {
body: bodyJsonSchema,
querystring: queryStringJsonSchema,
params: paramsJsonSchema,
headers: headersJsonSchema
}
app.post('/the/url', { schema }, handler)
Note that Ajv will try to coerce the values
to the types specified in your schema type
keywords, both to pass the
validation and to use the correctly typed data afterwards.
The Ajv default configuration in Iopa Schema Router supports coercing array parameters in
querystring
. Example:
const opts = {
schema: {
querystring: {
type: 'object',
properties: {
ids: {
type: 'array',
default: []
},
},
}
}
}
app.get('/', opts, (request, reply) => {
reply.send({ params: request.query }) // echo the querystring
})
server.listen({ port: 3000 }, (err) => {
if (err) throw err
})
Ajv Plugins
You can provide a list of plugins you want to use with the default ajv
instance. Note that the plugin must be compatible with the Ajv version shipped
within Iopa Schema Router.
Refer to
ajv options
to check plugins format
app.use<ISchemaAppOptions>(SchemaRouter, 'Schema Router', {
ajv: {
plugins: [
require('ajv-merge-patch')
]
}
})
app.post('/', (context, next) => { return { ok: 1 }},
{
schema: {
body: {
$patch: {
source: {
type: 'object',
properties: {
q: {
type: 'string'
}
}
},
with: [
{
op: 'add',
path: '/properties/q',
value: { type: 'number' }
}
]
}
}
}
})
app.post('/foo', (context, next) => { return { ok: 1 }},
{
schema: {
body: {
$merge: {
source: {
type: 'object',
properties: {
q: {
type: 'string'
}
}
},
with: {
required: ['q']
}
}
}
}
})
Schema Validator Configuration
Iopa Schema Router's baseline ajv configuration is:
{
coerceTypes: true, // change data type of data to match type keyword
useDefaults: true, // replace missing properties and items with the values from corresponding default keyword
removeAdditional: true, // remove additional properties
// Explicitly set allErrors to `false`.
// When set to `true`, a DoS attack is possible.
allErrors: false
}
This baseline configuration can be modified by providing customOptions
to your Iopa Schema Router use statement.
Serialization
Usually, you will send your data to the clients as JSON, and Iopa Schema Router has a powerful tool to help you, fast-json-stringify, which is used if you have provided an output schema in the route options. We encourage you to use an output schema, as it can drastically increase throughput and help prevent accidental disclosure of sensitive information.
Example:
const schema = {
response: {
200: {
type: 'object',
properties: {
value: { type: 'string' },
otherValue: { type: 'boolean' }
}
}
}
}
app.post('/the/url', handler, { schema })
As you can see, the response schema is based on the status code. If you want to
use the same schema for multiple status codes, you can use '2xx'
or default
,
for example:
const schema = {
response: {
default: {
type: 'object',
properties: {
error: {
type: 'boolean',
default: true
}
}
},
'2xx': {
type: 'object',
properties: {
value: { type: 'string' },
otherValue: { type: 'boolean' }
}
},
201: {
// the contract syntax
value: { type: 'string' }
}
}
}
app.post('/the/url', handler, { schema })
Error Handling
When schema validation fails for a request, Iopa Schema Router will automatically return a status 400 response including the result from the validator in the payload. As an example, if you have the following schema for your route
const schema = {
body: {
type: 'object',
properties: {
name: { type: 'string' }
},
required: ['name']
}
}
and fail to satisfy it, the route will immediately return a response with the following payload
{
"statusCode": 400,
"error": "Bad Request",
"message": "body should have required property 'name'"
}
JSON Schema support
JSON Schema provides utilities to optimize your schemas that, in conjunction with Iopa Schema Router's shared schema, let you reuse all your schemas easily.
Use Case | Validator | Serializer |
---|---|---|
$ref to $id
|
️️ |
|
$ref to /definitions
|
||
$ref to shared schema $id
|
||
$ref to shared schema /definitions
|
Examples
$ref
to $id
in same JSON Schema
Usage of const refToId = {
type: 'object',
definitions: {
foo: {
$id: '#address',
type: 'object',
properties: {
city: { type: 'string' }
}
}
},
properties: {
home: { $ref: '#address' },
work: { $ref: '#address' }
}
}
$ref
to /definitions
in same JSON Schema
Usage of const refToDefinitions = {
type: 'object',
definitions: {
foo: {
$id: '#address',
type: 'object',
properties: {
city: { type: 'string' }
}
}
},
properties: {
home: { $ref: '#/definitions/foo' },
work: { $ref: '#/definitions/foo' }
}
}
$ref
to a shared schema $id
as external schema
Usage app.addSchema({
$id: 'http://foo/common.json',
type: 'object',
definitions: {
foo: {
$id: '#address',
type: 'object',
properties: {
city: { type: 'string' }
}
}
}
})
const refToSharedSchemaId = {
type: 'object',
properties: {
home: { $ref: 'http://foo/common.json#address' },
work: { $ref: 'http://foo/common.json#address' }
}
}
$ref
to a shared schema /definitions
as external schema
Usage app.addSchema({
$id: 'http://foo/shared.json',
type: 'object',
definitions: {
foo: {
type: 'object',
properties: {
city: { type: 'string' }
}
}
}
})
const refToSharedSchemaDefinitions = {
type: 'object',
properties: {
home: { $ref: 'http://foo/shared.json#/definitions/foo' },
work: { $ref: 'http://foo/shared.json#/definitions/foo' }
}
}
Resources
- JSON Schema
- Understanding JSON Schema
- fast-json-stringify documentation
- Ajv documentation
- Ajv i18n
- Ajv custom errors
License
MIT