diff --git a/packages/xo-server/src/xo-mixins/_xoJsonSchema.mjs b/packages/xo-server/src/xo-mixins/_xoJsonSchema.mjs new file mode 100644 index 000000000..e0233e0f0 --- /dev/null +++ b/packages/xo-server/src/xo-mixins/_xoJsonSchema.mjs @@ -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)) +} diff --git a/packages/xo-server/src/xo-mixins/api.mjs b/packages/xo-server/src/xo-mixins/api.mjs index 918a35e04..860b29bfe 100644 --- a/packages/xo-server/src/xo-mixins/api.mjs +++ b/packages/xo-server/src/xo-mixins/api.mjs @@ -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 } diff --git a/packages/xo-server/src/xo-mixins/rest-api.mjs b/packages/xo-server/src/xo-mixins/rest-api.mjs index 5f758ccd1..ef40aca91 100644 --- a/packages/xo-server/src/xo-mixins/rest-api.mjs +++ b/packages/xo-server/src/xo-mixins/rest-api.mjs @@ -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 }) => { - const ref = await vm.$snapshot({ name_label }) - return vm.$xapi.getField('VM', ref, 'uuid') - }, + 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 {