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 { createLogger } from '@xen-orchestra/log'
|
||||
|
||||
import Ajv from 'ajv'
|
||||
import cloneDeep from 'lodash/cloneDeep.js'
|
||||
import forEach from 'lodash/forEach.js'
|
||||
import kindOf from 'kindof'
|
||||
@ -15,6 +14,7 @@ import Connection from '../connection.mjs'
|
||||
import { noop, serializeError } from '../utils.mjs'
|
||||
|
||||
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 ajv = new Ajv({ allErrors: true, allowUnionTypes: true, useDefaults: true })
|
||||
|
||||
function checkParams(method, params) {
|
||||
// Parameters suffixed by `?` are marked as ignorable by the client and
|
||||
// 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) {
|
||||
const resolve = method.resolve
|
||||
if (!resolve) {
|
||||
@ -298,15 +222,12 @@ export default class Api {
|
||||
|
||||
let validate
|
||||
if (params !== undefined) {
|
||||
let schema = { type: 'object', properties: cloneDeep(params) }
|
||||
try {
|
||||
schema = adaptJsonSchema(schema)
|
||||
validate = ajv.compile(schema)
|
||||
validate = compileXoJsonSchema({ type: 'object', properties: cloneDeep(params) })
|
||||
} catch (error) {
|
||||
log.warn('failed to compile method params schema', {
|
||||
error,
|
||||
method: name,
|
||||
schema,
|
||||
})
|
||||
throw error
|
||||
}
|
||||
|
@ -5,12 +5,14 @@ import { ifDef } from '@xen-orchestra/defined'
|
||||
import { featureUnauthorized, invalidCredentials, noSuchObject } from 'xo-common/api-errors.js'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import { json, Router } from 'express'
|
||||
import cloneDeep from 'lodash/cloneDeep.js'
|
||||
import path from 'node:path'
|
||||
import pick from 'lodash/pick.js'
|
||||
import * as CM from 'complex-matcher'
|
||||
import { VDI_FORMAT_RAW, VDI_FORMAT_VHD } from '@xen-orchestra/xapi'
|
||||
|
||||
import { getUserPublicProperties } from '../utils.mjs'
|
||||
import { compileXoJsonSchema } from './_xoJsonSchema.mjs'
|
||||
|
||||
const { join } = path.posix
|
||||
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 = {
|
||||
__proto__: null,
|
||||
|
||||
@ -248,10 +255,13 @@ export default class RestApi {
|
||||
clean_shutdown: ({ xapiObject: vm }) => vm.$callAsync('clean_shutdown').then(noop),
|
||||
hard_reboot: ({ xapiObject: vm }) => vm.$callAsync('hard_reboot').then(noop),
|
||||
hard_shutdown: ({ xapiObject: vm }) => vm.$callAsync('hard_shutdown').then(noop),
|
||||
snapshot: async ({ xapiObject: vm }, { name_label }) => {
|
||||
snapshot: withParams(
|
||||
async ({ xapiObject: vm }, { name_label }) => {
|
||||
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),
|
||||
}
|
||||
|
||||
@ -594,9 +604,19 @@ export default class RestApi {
|
||||
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 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')) {
|
||||
pResult.then(result => res.json(result), next)
|
||||
} else {
|
||||
|
Loading…
Reference in New Issue
Block a user