mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-09 17:06:13 -06:00
Compare commits
2 Commits
8025af3cc8
...
rest-api-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f18e799646 | ||
|
|
f91ae35322 |
56
packages/xo-server/src/rest-api/AbstractCollection.mjs
Normal file
56
packages/xo-server/src/rest-api/AbstractCollection.mjs
Normal file
@@ -0,0 +1,56 @@
|
||||
import CM from 'complex-matcher'
|
||||
import { every } from '@vates/predicates'
|
||||
import AbstractEntity from './AbstractEntity.mjs'
|
||||
import pick from 'lodash/pick.js'
|
||||
import { noSuchObject } from 'xo-common/api-errors.js'
|
||||
class AbstractCollection extends AbstractEntity {
|
||||
getObject(uuid) {
|
||||
return this.getApp().getObject(uuid, this.getType())
|
||||
}
|
||||
|
||||
getObjects(filter, limit, fields) {
|
||||
filter = filter !== undefined ? CM.parse(filter).createPredicate() : undefined
|
||||
|
||||
let objects = this.getApp().getObjects({
|
||||
filter: every(obj => obj.type === this.getType(), filter),
|
||||
limit,
|
||||
})
|
||||
|
||||
if (fields === undefined) {
|
||||
objects = Object.keys(objects)
|
||||
} else {
|
||||
objects = Object.values(objects)
|
||||
if (fields !== '*') {
|
||||
const properties = fields.split(',')
|
||||
objects = objects.map(obj => pick(obj, properties))
|
||||
}
|
||||
}
|
||||
|
||||
return objects
|
||||
}
|
||||
|
||||
registerRoutes() {
|
||||
this.getRouter()
|
||||
.param('uuid', async (req, res, next) => {
|
||||
const uuid = req.params.uuid
|
||||
|
||||
try {
|
||||
res.locals.object = await this.getObject(uuid)
|
||||
next()
|
||||
} catch (error) {
|
||||
if (noSuchObject.is(error)) {
|
||||
return next('route')
|
||||
}
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
.get(`/`, async (req, res) => {
|
||||
const objects = await this.getObjects(req.query.filter, req.query.limit, req.query.fields)
|
||||
|
||||
this.sendObjects(objects, req, res)
|
||||
})
|
||||
.get(`/:uuid`, (req, res) => this.sendObject(res.locals.object, req, res))
|
||||
}
|
||||
}
|
||||
|
||||
export default AbstractCollection
|
||||
79
packages/xo-server/src/rest-api/AbstractEntity.mjs
Normal file
79
packages/xo-server/src/rest-api/AbstractEntity.mjs
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Router } from 'express'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import AbstractCollection from './AbstractCollection.mjs'
|
||||
import pick from 'lodash/pick.js'
|
||||
|
||||
// @TODO Move to utils folder
|
||||
async function* makeJsonStream(iterable) {
|
||||
yield '['
|
||||
let first = true
|
||||
for await (const object of iterable) {
|
||||
if (first) {
|
||||
first = false
|
||||
yield '\n'
|
||||
} else {
|
||||
yield ',\n'
|
||||
}
|
||||
yield JSON.stringify(object, null, 2)
|
||||
}
|
||||
yield '\n]\n'
|
||||
}
|
||||
|
||||
async function* makeNdJsonStream(iterable) {
|
||||
for await (const object of iterable) {
|
||||
yield JSON.stringify(object)
|
||||
yield '\n'
|
||||
}
|
||||
}
|
||||
// ---------
|
||||
|
||||
class AbstractEntity {
|
||||
#app
|
||||
#type
|
||||
#router
|
||||
constructor(app, type) {
|
||||
this.#app = app
|
||||
this.#type = type
|
||||
this.#router = new Router()
|
||||
}
|
||||
|
||||
getApp() {
|
||||
return this.#app
|
||||
}
|
||||
|
||||
getRouter() {
|
||||
return this.#router
|
||||
}
|
||||
|
||||
getType() {
|
||||
return this.#type
|
||||
}
|
||||
|
||||
registerRoutes() {
|
||||
throw new Error('Need to be implemented by the children entity')
|
||||
}
|
||||
|
||||
registerRouter(router) {
|
||||
this.registerRoutes()
|
||||
|
||||
const prefix = `/${this.getType().toLowerCase()}${this instanceof AbstractCollection ? 's' : ''}`
|
||||
router.use(prefix, this.getRouter())
|
||||
}
|
||||
|
||||
sendObjects(iterable, req, res) {
|
||||
const ndjson = req.headers.accept === 'application/x-ndjson'
|
||||
res.setHeader('content-type', ndjson ? 'application/x-ndjson' : 'application/json')
|
||||
return pipeline((ndjson ? makeNdJsonStream : makeJsonStream)(iterable), res)
|
||||
}
|
||||
|
||||
sendObject(object, req, res) {
|
||||
const fields = req.query.fields
|
||||
if (fields !== undefined && fields !== '*') {
|
||||
object = pick(object, fields.split())
|
||||
}
|
||||
|
||||
res.json(object)
|
||||
}
|
||||
}
|
||||
|
||||
export default AbstractEntity
|
||||
20
packages/xo-server/src/rest-api/dashboard.mjs
Normal file
20
packages/xo-server/src/rest-api/dashboard.mjs
Normal file
@@ -0,0 +1,20 @@
|
||||
import AbstractEntity from './AbstractEntity.mjs'
|
||||
|
||||
class Dashboard extends AbstractEntity {
|
||||
constructor(app) {
|
||||
super(app, 'dashboard')
|
||||
}
|
||||
|
||||
computeSomeRandomData() {
|
||||
return {
|
||||
nPool: Math.round(Math.random() * 100),
|
||||
nHosts: Math.round(Math.random() * 100),
|
||||
}
|
||||
}
|
||||
|
||||
registerRoutes() {
|
||||
this.getRouter().get('/', (req, res) => this.sendObject(this.computeSomeRandomData(), req, res))
|
||||
}
|
||||
}
|
||||
|
||||
export default Dashboard
|
||||
4
packages/xo-server/src/rest-api/entities.mjs
Normal file
4
packages/xo-server/src/rest-api/entities.mjs
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as Host } from './host.mjs'
|
||||
export { default as Vm } from './vm.mjs'
|
||||
export { default as User } from './user.mjs'
|
||||
export { default as Dashboard } from './dashboard.mjs'
|
||||
9
packages/xo-server/src/rest-api/host.mjs
Normal file
9
packages/xo-server/src/rest-api/host.mjs
Normal file
@@ -0,0 +1,9 @@
|
||||
import AbstractCollection from './AbstractCollection.mjs'
|
||||
|
||||
class Host extends AbstractCollection {
|
||||
constructor(app) {
|
||||
super(app, 'host')
|
||||
}
|
||||
}
|
||||
|
||||
export default Host
|
||||
14
packages/xo-server/src/rest-api/middleware.mjs
Normal file
14
packages/xo-server/src/rest-api/middleware.mjs
Normal file
@@ -0,0 +1,14 @@
|
||||
import { invalidCredentials } from 'xo-common/api-errors.js'
|
||||
|
||||
export const authenticateUserFromToken = app => async (req, res, next) => {
|
||||
const { cookies, ip } = req
|
||||
try {
|
||||
const user = await app.authenticateUser({ token: cookies.authenticationToken ?? cookies.token }, { ip })
|
||||
return app.runWithApiContext(user, next)
|
||||
} catch (error) {
|
||||
if (invalidCredentials.is(error)) {
|
||||
return res.sendStatus(401)
|
||||
}
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
41
packages/xo-server/src/rest-api/user.mjs
Normal file
41
packages/xo-server/src/rest-api/user.mjs
Normal file
@@ -0,0 +1,41 @@
|
||||
import pick from 'lodash/pick.js'
|
||||
import AbstractCollection from './AbstractCollection.mjs'
|
||||
import CM from 'complex-matcher'
|
||||
|
||||
class User extends AbstractCollection {
|
||||
constructor(app) {
|
||||
super(app, 'user')
|
||||
}
|
||||
|
||||
async getObject(id) {
|
||||
const user = await this.getApp().getUser(id)
|
||||
delete user.pw_hash
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
async getObjects(filter, limit, fields) {
|
||||
filter = filter !== undefined ? CM.parse(filter).createPredicate() : undefined
|
||||
limit = Number(limit)
|
||||
|
||||
let users = await this.getApp().getAllUsers()
|
||||
if (filter !== undefined) {
|
||||
users = users.filter(filter)
|
||||
}
|
||||
|
||||
if (Number.isInteger(limit)) {
|
||||
users = users.slice(0, limit)
|
||||
}
|
||||
|
||||
if (fields === undefined) {
|
||||
users = users.map(user => user.id)
|
||||
} else if (fields !== '*') {
|
||||
const properties = fields.split(',')
|
||||
users = users.map(obj => pick(obj, properties))
|
||||
}
|
||||
|
||||
return users
|
||||
}
|
||||
}
|
||||
|
||||
export default User
|
||||
20
packages/xo-server/src/rest-api/vm.mjs
Normal file
20
packages/xo-server/src/rest-api/vm.mjs
Normal file
@@ -0,0 +1,20 @@
|
||||
import AbstractCollection from './AbstractCollection.mjs'
|
||||
|
||||
class Vm extends AbstractCollection {
|
||||
constructor(app) {
|
||||
super(app, 'VM')
|
||||
}
|
||||
|
||||
registerRoutes() {
|
||||
super.registerRoutes()
|
||||
this.getRouter()
|
||||
.get('/foo', (req, res) => {
|
||||
res.json('foo')
|
||||
})
|
||||
.get('/:uuid/name_label', (req, res) => {
|
||||
return res.json(res.locals.object.name_label)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default Vm
|
||||
@@ -1,33 +1,15 @@
|
||||
import { asyncEach } from '@vates/async-each'
|
||||
import { createGzip } from 'node:zlib'
|
||||
import { defer } from 'golike-defer'
|
||||
import { every } from '@vates/predicates'
|
||||
import { extractIdsFromSimplePattern } from '@xen-orchestra/backups/extractIdsFromSimplePattern.mjs'
|
||||
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 { Readable } from 'node:stream'
|
||||
import cloneDeep from 'lodash/cloneDeep.js'
|
||||
import groupBy from 'lodash/groupBy.js'
|
||||
import path from 'node:path'
|
||||
import pDefer from 'promise-toolbox/defer'
|
||||
import pick from 'lodash/pick.js'
|
||||
import semver from 'semver'
|
||||
import * as CM from 'complex-matcher'
|
||||
import { VDI_FORMAT_RAW, VDI_FORMAT_VHD } from '@xen-orchestra/xapi'
|
||||
import { parse } from 'xo-remote-parser'
|
||||
import { Router } from 'express'
|
||||
import * as entities from '../rest-api/entities.mjs'
|
||||
import { authenticateUserFromToken } from '../rest-api/middleware.mjs'
|
||||
|
||||
import {
|
||||
getFromAsyncCache,
|
||||
getUserPublicProperties,
|
||||
isAlarm,
|
||||
isReplicaVm,
|
||||
isSrWritable,
|
||||
vmContainsNoBakTag,
|
||||
} from '../utils.mjs'
|
||||
import { compileXoJsonSchema } from './_xoJsonSchema.mjs'
|
||||
import { createPredicate } from 'value-matcher'
|
||||
// @TODO:
|
||||
// - implement middlewhare to retrieve user from a token
|
||||
// - Do a non XAPI collection test (like users,dashboard, group, backups,...)
|
||||
// - maybe rename AbstractXapiCollection into AbstractCollection? (for users, groups, ...) to be able to extends it?
|
||||
// - expose an action
|
||||
// - handle sync an async actions
|
||||
// - how to handle "/" and "/:uuid" with user permission
|
||||
// - expose openapi + swagger docs
|
||||
|
||||
// E.g: 'value: 0.6\nconfig:\n<variable>\n<name value="cpu_usage"/>\n<alarm_trigger_level value="0.4"/>\n<alarm_trigger_period value ="60"/>\n</variable>';
|
||||
const ALARM_BODY_REGEX = /^value:\s*(Infinity|NaN|-Infinity|\d+(?:\.\d+)?)\s*config:\s*<variable>\s*<name value="(.*?)"/
|
||||
@@ -494,8 +476,6 @@ async function _getDashboardStats(app) {
|
||||
|
||||
const keepNonAlarmMessages = message => message.type === 'message' && !isAlarm(message)
|
||||
export default class RestApi {
|
||||
#api
|
||||
|
||||
constructor(app, { express }) {
|
||||
// don't setup the API if express is not present
|
||||
//
|
||||
@@ -504,64 +484,13 @@ export default class RestApi {
|
||||
return
|
||||
}
|
||||
|
||||
const api = subRouter(express, '/rest/v0')
|
||||
this.#api = api
|
||||
express.use(authenticateUserFromToken(app))
|
||||
|
||||
// register the route BEFORE the authentication middleware because this route does not require authentication
|
||||
api.post('/users/authentication_tokens', json(), async (req, res) => {
|
||||
const authorization = req.headers.authorization ?? ''
|
||||
const [, encodedCredentials] = authorization.split(' ')
|
||||
if (encodedCredentials === undefined) {
|
||||
return res.status(401).json('missing credentials')
|
||||
}
|
||||
const coreRouter = new Router()
|
||||
|
||||
const [username, password] = Buffer.from(encodedCredentials, 'base64').toString().split(':')
|
||||
|
||||
try {
|
||||
const { user } = await app.authenticateUser({ username, password, otp: req.query.otp })
|
||||
const token = await app.createAuthenticationToken({
|
||||
client: req.body.client,
|
||||
userId: user.id,
|
||||
description: req.body.description,
|
||||
expiresIn: req.body.expiresIn,
|
||||
})
|
||||
res.json({ token })
|
||||
} catch (error) {
|
||||
if (invalidCredentials.is(error)) {
|
||||
res.status(401)
|
||||
} else {
|
||||
res.status(400)
|
||||
}
|
||||
res.json(error.message)
|
||||
}
|
||||
})
|
||||
|
||||
api.use((req, res, next) => {
|
||||
const { cookies, ip } = req
|
||||
app.authenticateUser({ token: cookies.authenticationToken ?? cookies.token }, { ip }).then(
|
||||
({ user }) => {
|
||||
if (user.permission === 'admin') {
|
||||
return app.runWithApiContext(user, next)
|
||||
}
|
||||
|
||||
res.sendStatus(401)
|
||||
},
|
||||
error => {
|
||||
if (invalidCredentials.is(error)) {
|
||||
res.sendStatus(401)
|
||||
} else {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const collections = { __proto__: null }
|
||||
|
||||
const withParams = (fn, paramsSchema) => {
|
||||
fn.params = paramsSchema
|
||||
fn.validateParams = compileXoJsonSchema({ type: 'object', properties: cloneDeep(paramsSchema) })
|
||||
return fn
|
||||
for (const entity in entities) {
|
||||
const instance = new entities[entity](app)
|
||||
instance.registerRouter(coreRouter)
|
||||
}
|
||||
|
||||
{
|
||||
@@ -1471,32 +1400,7 @@ export default class RestApi {
|
||||
)
|
||||
}
|
||||
|
||||
registerRestApi(spec, base = '/') {
|
||||
for (const path of Object.keys(spec)) {
|
||||
if (path[0] === '_') {
|
||||
const handler = spec[path]
|
||||
this.#api[path.slice(1)](base, json(), async (req, res, next) => {
|
||||
try {
|
||||
const result = await handler(req, res, next)
|
||||
if (result !== undefined) {
|
||||
const isIterable =
|
||||
result !== null && typeof (result[Symbol.iterator] ?? result[Symbol.asyncIterator]) === 'function'
|
||||
if (isIterable) {
|
||||
await sendObjects(result, req, res)
|
||||
} else {
|
||||
res.json(result)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
this.registerRestApi(spec[path], join(base, path))
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
throw new Error('not implemented')
|
||||
}
|
||||
registerRestApi() {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user