Compare commits

...

2 Commits

Author SHA1 Message Date
mathieuRA
f18e799646 rebase me 2025-01-15 18:29:21 +01:00
mathieuRA
f91ae35322 POC: new rest-api structure + openapi/swagger 2025-01-15 18:29:19 +01:00
9 changed files with 261 additions and 114 deletions

View 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

View 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

View 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

View 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'

View File

@@ -0,0 +1,9 @@
import AbstractCollection from './AbstractCollection.mjs'
class Host extends AbstractCollection {
constructor(app) {
super(app, 'host')
}
}
export default Host

View 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)
}
}

View 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

View 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

View File

@@ -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() {
//
}
}