feat(xo-server/rest-api): validate params
This commit is contained in:
parent
8a7abc2e54
commit
4f383635ef
95
packages/xo-server/src/xo-mixins/_xoJsonSchema.mjs
Normal file
95
packages/xo-server/src/xo-mixins/_xoJsonSchema.mjs
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import Ajv from 'ajv'
|
||||||
|
|
||||||
|
const ajv = new Ajv({ allErrors: true, allowUnionTypes: true, useDefaults: true })
|
||||||
|
|
||||||
|
function makeIsType({ type }) {
|
||||||
|
if (typeof type === 'string') {
|
||||||
|
return t => t === type
|
||||||
|
}
|
||||||
|
|
||||||
|
const types = new Set(type)
|
||||||
|
return t => types.has(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform an XO JSON schema to a standard JSON schema
|
||||||
|
*
|
||||||
|
* Differences of XO JSON schemas:
|
||||||
|
* - objects:
|
||||||
|
* - properties are required by default
|
||||||
|
* - properties can be marked as `optional` in place of listing them with `required`
|
||||||
|
* - additional properties disabled by default
|
||||||
|
* - a wildcard `*` property can be used in place of `additionalProperties`
|
||||||
|
* - strings must be non emtpy by default
|
||||||
|
*/
|
||||||
|
function xoToJsonSchema(schema) {
|
||||||
|
if (schema.enum !== undefined) {
|
||||||
|
return schema
|
||||||
|
}
|
||||||
|
|
||||||
|
const is = makeIsType(schema)
|
||||||
|
|
||||||
|
if (is('array')) {
|
||||||
|
const { items } = schema
|
||||||
|
if (items !== undefined) {
|
||||||
|
if (Array.isArray(items)) {
|
||||||
|
for (let i = 0, n = items.length; i < n; ++i) {
|
||||||
|
items[i] = xoToJsonSchema(items[i])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
schema.items = xoToJsonSchema(items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is('object')) {
|
||||||
|
const { properties = {} } = schema
|
||||||
|
let keys = Object.keys(properties)
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
properties[key] = xoToJsonSchema(properties[key])
|
||||||
|
}
|
||||||
|
|
||||||
|
const { additionalProperties } = schema
|
||||||
|
if (additionalProperties === undefined) {
|
||||||
|
const wildCard = properties['*']
|
||||||
|
if (wildCard === undefined) {
|
||||||
|
// we want additional properties to be disabled by default unless no properties are defined
|
||||||
|
schema.additionalProperties = keys.length === 0
|
||||||
|
} else {
|
||||||
|
delete properties['*']
|
||||||
|
keys = Object.keys(properties)
|
||||||
|
schema.additionalProperties = wildCard
|
||||||
|
}
|
||||||
|
} else if (typeof additionalProperties === 'object') {
|
||||||
|
schema.additionalProperties = xoToJsonSchema(additionalProperties)
|
||||||
|
}
|
||||||
|
|
||||||
|
// we want properties to be required by default unless explicitly marked so
|
||||||
|
// we use property `optional` instead of object `required`
|
||||||
|
if (schema.required === undefined) {
|
||||||
|
const required = keys.filter(key => {
|
||||||
|
const value = properties[key]
|
||||||
|
const required = !value.optional
|
||||||
|
delete value.optional
|
||||||
|
return required
|
||||||
|
})
|
||||||
|
if (required.length !== 0) {
|
||||||
|
schema.required = required
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is('string')) {
|
||||||
|
// we want strings to be not empty by default
|
||||||
|
if (schema.minLength === undefined && schema.format === undefined && schema.pattern === undefined) {
|
||||||
|
schema.minLength = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return schema
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compileXoJsonSchema(schema) {
|
||||||
|
return ajv.compile(xoToJsonSchema(schema))
|
||||||
|
}
|
@ -1,7 +1,6 @@
|
|||||||
import emitAsync from '@xen-orchestra/emit-async'
|
import emitAsync from '@xen-orchestra/emit-async'
|
||||||
import { createLogger } from '@xen-orchestra/log'
|
import { createLogger } from '@xen-orchestra/log'
|
||||||
|
|
||||||
import Ajv from 'ajv'
|
|
||||||
import cloneDeep from 'lodash/cloneDeep.js'
|
import cloneDeep from 'lodash/cloneDeep.js'
|
||||||
import forEach from 'lodash/forEach.js'
|
import forEach from 'lodash/forEach.js'
|
||||||
import kindOf from 'kindof'
|
import kindOf from 'kindof'
|
||||||
@ -15,6 +14,7 @@ import Connection from '../connection.mjs'
|
|||||||
import { noop, serializeError } from '../utils.mjs'
|
import { noop, serializeError } from '../utils.mjs'
|
||||||
|
|
||||||
import * as errors from 'xo-common/api-errors.js'
|
import * as errors from 'xo-common/api-errors.js'
|
||||||
|
import { compileXoJsonSchema } from './_xoJsonSchema.mjs'
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|
||||||
@ -56,8 +56,6 @@ const XAPI_ERROR_TO_XO_ERROR = {
|
|||||||
|
|
||||||
const hasPermission = (actual, expected) => PERMISSIONS[actual] >= PERMISSIONS[expected]
|
const hasPermission = (actual, expected) => PERMISSIONS[actual] >= PERMISSIONS[expected]
|
||||||
|
|
||||||
const ajv = new Ajv({ allErrors: true, allowUnionTypes: true, useDefaults: true })
|
|
||||||
|
|
||||||
function checkParams(method, params) {
|
function checkParams(method, params) {
|
||||||
// Parameters suffixed by `?` are marked as ignorable by the client and
|
// Parameters suffixed by `?` are marked as ignorable by the client and
|
||||||
// ignored if unsupported by this version of the API
|
// ignored if unsupported by this version of the API
|
||||||
@ -117,80 +115,6 @@ function checkPermission(method) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function adaptJsonSchema(schema) {
|
|
||||||
if (schema.enum !== undefined) {
|
|
||||||
return schema
|
|
||||||
}
|
|
||||||
|
|
||||||
const is = (({ type }) => {
|
|
||||||
if (typeof type === 'string') {
|
|
||||||
return t => t === type
|
|
||||||
}
|
|
||||||
const types = new Set(type)
|
|
||||||
return t => types.has(t)
|
|
||||||
})(schema)
|
|
||||||
|
|
||||||
if (is('array')) {
|
|
||||||
const { items } = schema
|
|
||||||
if (items !== undefined) {
|
|
||||||
if (Array.isArray(items)) {
|
|
||||||
for (let i = 0, n = items.length; i < n; ++i) {
|
|
||||||
items[i] = adaptJsonSchema(items[i])
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
schema.items = adaptJsonSchema(items)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is('object')) {
|
|
||||||
const { properties = {} } = schema
|
|
||||||
let keys = Object.keys(properties)
|
|
||||||
|
|
||||||
for (const key of keys) {
|
|
||||||
properties[key] = adaptJsonSchema(properties[key])
|
|
||||||
}
|
|
||||||
|
|
||||||
const { additionalProperties } = schema
|
|
||||||
if (additionalProperties === undefined) {
|
|
||||||
const wildCard = properties['*']
|
|
||||||
if (wildCard === undefined) {
|
|
||||||
// we want additional properties to be disabled by default unless no properties are defined
|
|
||||||
schema.additionalProperties = keys.length === 0
|
|
||||||
} else {
|
|
||||||
delete properties['*']
|
|
||||||
keys = Object.keys(properties)
|
|
||||||
schema.additionalProperties = wildCard
|
|
||||||
}
|
|
||||||
} else if (typeof additionalProperties === 'object') {
|
|
||||||
schema.additionalProperties = adaptJsonSchema(additionalProperties)
|
|
||||||
}
|
|
||||||
|
|
||||||
// we want properties to be required by default unless explicitly marked so
|
|
||||||
// we use property `optional` instead of object `required`
|
|
||||||
if (schema.required === undefined) {
|
|
||||||
const required = keys.filter(key => {
|
|
||||||
const value = properties[key]
|
|
||||||
const required = !value.optional
|
|
||||||
delete value.optional
|
|
||||||
return required
|
|
||||||
})
|
|
||||||
if (required.length !== 0) {
|
|
||||||
schema.required = required
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is('string')) {
|
|
||||||
// we want strings to be not empty by default
|
|
||||||
if (schema.minLength === undefined && schema.format === undefined && schema.pattern === undefined) {
|
|
||||||
schema.minLength = 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return schema
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveParams(method, params) {
|
async function resolveParams(method, params) {
|
||||||
const resolve = method.resolve
|
const resolve = method.resolve
|
||||||
if (!resolve) {
|
if (!resolve) {
|
||||||
@ -298,15 +222,12 @@ export default class Api {
|
|||||||
|
|
||||||
let validate
|
let validate
|
||||||
if (params !== undefined) {
|
if (params !== undefined) {
|
||||||
let schema = { type: 'object', properties: cloneDeep(params) }
|
|
||||||
try {
|
try {
|
||||||
schema = adaptJsonSchema(schema)
|
validate = compileXoJsonSchema({ type: 'object', properties: cloneDeep(params) })
|
||||||
validate = ajv.compile(schema)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.warn('failed to compile method params schema', {
|
log.warn('failed to compile method params schema', {
|
||||||
error,
|
error,
|
||||||
method: name,
|
method: name,
|
||||||
schema,
|
|
||||||
})
|
})
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
@ -5,12 +5,14 @@ import { ifDef } from '@xen-orchestra/defined'
|
|||||||
import { featureUnauthorized, invalidCredentials, noSuchObject } from 'xo-common/api-errors.js'
|
import { featureUnauthorized, invalidCredentials, noSuchObject } from 'xo-common/api-errors.js'
|
||||||
import { pipeline } from 'node:stream/promises'
|
import { pipeline } from 'node:stream/promises'
|
||||||
import { json, Router } from 'express'
|
import { json, Router } from 'express'
|
||||||
|
import cloneDeep from 'lodash/cloneDeep.js'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import pick from 'lodash/pick.js'
|
import pick from 'lodash/pick.js'
|
||||||
import * as CM from 'complex-matcher'
|
import * as CM from 'complex-matcher'
|
||||||
import { VDI_FORMAT_RAW, VDI_FORMAT_VHD } from '@xen-orchestra/xapi'
|
import { VDI_FORMAT_RAW, VDI_FORMAT_VHD } from '@xen-orchestra/xapi'
|
||||||
|
|
||||||
import { getUserPublicProperties } from '../utils.mjs'
|
import { getUserPublicProperties } from '../utils.mjs'
|
||||||
|
import { compileXoJsonSchema } from './_xoJsonSchema.mjs'
|
||||||
|
|
||||||
const { join } = path.posix
|
const { join } = path.posix
|
||||||
const noop = Function.prototype
|
const noop = Function.prototype
|
||||||
@ -227,6 +229,11 @@ export default class RestApi {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const withParams = (fn, paramsSchema) => {
|
||||||
|
fn.validateParams = compileXoJsonSchema({ type: 'object', properties: cloneDeep(paramsSchema) })
|
||||||
|
return fn
|
||||||
|
}
|
||||||
|
|
||||||
collections.pools.actions = {
|
collections.pools.actions = {
|
||||||
__proto__: null,
|
__proto__: null,
|
||||||
|
|
||||||
@ -248,10 +255,13 @@ export default class RestApi {
|
|||||||
clean_shutdown: ({ xapiObject: vm }) => vm.$callAsync('clean_shutdown').then(noop),
|
clean_shutdown: ({ xapiObject: vm }) => vm.$callAsync('clean_shutdown').then(noop),
|
||||||
hard_reboot: ({ xapiObject: vm }) => vm.$callAsync('hard_reboot').then(noop),
|
hard_reboot: ({ xapiObject: vm }) => vm.$callAsync('hard_reboot').then(noop),
|
||||||
hard_shutdown: ({ xapiObject: vm }) => vm.$callAsync('hard_shutdown').then(noop),
|
hard_shutdown: ({ xapiObject: vm }) => vm.$callAsync('hard_shutdown').then(noop),
|
||||||
snapshot: async ({ xapiObject: vm }, { name_label }) => {
|
snapshot: withParams(
|
||||||
const ref = await vm.$snapshot({ name_label })
|
async ({ xapiObject: vm }, { name_label }) => {
|
||||||
return vm.$xapi.getField('VM', ref, 'uuid')
|
const ref = await vm.$snapshot({ name_label })
|
||||||
},
|
return vm.$xapi.getField('VM', ref, 'uuid')
|
||||||
|
},
|
||||||
|
{ name_label: { type: 'string', optional: true } }
|
||||||
|
),
|
||||||
start: ({ xapiObject: vm }) => vm.$callAsync('start', false, false).then(noop),
|
start: ({ xapiObject: vm }) => vm.$callAsync('start', false, false).then(noop),
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -594,9 +604,19 @@ export default class RestApi {
|
|||||||
return next()
|
return next()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const params = req.body
|
||||||
|
|
||||||
|
const { validateParams } = fn
|
||||||
|
if (validateParams !== undefined) {
|
||||||
|
if (!validateParams(params)) {
|
||||||
|
res.statusCode = 400
|
||||||
|
return res.json(validateParams.errors)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { xapiObject, xoObject } = req
|
const { xapiObject, xoObject } = req
|
||||||
const task = app.tasks.create({ name: `REST: ${action} ${req.collection.type}`, objectId: xoObject.id })
|
const task = app.tasks.create({ name: `REST: ${action} ${req.collection.type}`, objectId: xoObject.id })
|
||||||
const pResult = task.run(() => fn({ xapiObject, xoObject }, req.body))
|
const pResult = task.run(() => fn({ xapiObject, xoObject }, params))
|
||||||
if (Object.hasOwn(req.query, 'sync')) {
|
if (Object.hasOwn(req.query, 'sync')) {
|
||||||
pResult.then(result => res.json(result), next)
|
pResult.then(result => res.json(result), next)
|
||||||
} else {
|
} else {
|
||||||
|
Loading…
Reference in New Issue
Block a user