Compare commits

...

23 Commits

Author SHA1 Message Date
mathieuRA
2102bec236 add listener on Index to be able ton get the collection type even if the event remove is emit 2025-01-30 12:34:24 +01:00
mathieuRA
f3ff78f790 possibility to register route from outside 2025-01-29 17:22:40 +01:00
mathieuRA
d97873242a export import/export vdi 2025-01-27 14:36:47 +01:00
mathieuRA
ce651df896 add small action 2025-01-24 18:42:22 +01:00
mathieuRA
bdcd8925c7 setup error middleware 2025-01-24 17:45:30 +01:00
mathieuRA
235d0ae103 setup auth middleware 2025-01-24 16:40:21 +01:00
mathieuRA
5ad7caeb1c mutualize some code + enhance typing 2025-01-24 15:51:29 +01:00
mathieuRA
34da6fd857 auto bind class for DI 2025-01-23 21:10:57 +01:00
mathieuRA
a62aec0e0b clean deps 2025-01-23 12:07:13 +01:00
mathieuRA
4bf887076d update dependency 2025-01-23 11:38:20 +01:00
mathieuRA
06dd432803 remove unused folder 2025-01-23 11:34:50 +01:00
mathieuRA
344906e5df use dependency injection 2025-01-23 11:32:14 +01:00
mathieuRA
6df0ebc135 expose dashboard + send event if changes 2025-01-23 11:32:14 +01:00
mathieuRA
22bc90847a remove unused code 2025-01-23 11:32:14 +01:00
mathieuRA
300d16ae1c do not pass all xoApp on the router 2025-01-23 11:32:14 +01:00
mathieuRA
d11526b277 watch event for XAPI objects 2025-01-23 11:32:14 +01:00
mathieuRA
8c7a427225 enhance XoApp types 2025-01-23 11:32:14 +01:00
mathieuRA
d0252cb13a fix various ts error 2025-01-23 11:32:14 +01:00
mathieuRA
906f35cf7b fix yarn.lock 2025-01-23 11:32:14 +01:00
mathieuRA
58c2235737 configure eslint 2025-01-23 11:32:14 +01:00
mathieuRA
10026de7a4 move into src folder 2025-01-23 11:32:14 +01:00
mathieuRA
1e84568dc6 expose vms and servers 2025-01-23 11:32:14 +01:00
mathieuRA
86effd8117 WIP: REST-API with auto-generated swagger documentation 2025-01-23 11:32:12 +01:00
28 changed files with 1683 additions and 16 deletions

View File

@@ -0,0 +1,19 @@
{
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
"overrides": [
{
"files": ["*.ts"],
"parserOptions": {
"sourceType": "module"
},
"rules": {
"no-extra-semi": "off",
"no-use-before-define": "off",
"@typescript-eslint/no-explicit-any": "off"
}
}
],
"ignorePatterns": ["src/open-api/routes/routes.ts"]
}

1
@xen-orchestra/rest-api/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
src/open-api/*

View File

@@ -0,0 +1,34 @@
{
"author": "Vates",
"name": "@xen-orchestra/rest-api",
"version": "0.1.0",
"description": "",
"main": "./dist/index.js",
"license": "MIT",
"private": true,
"type": "module",
"engines": {
"node": ">=20"
},
"scripts": {
"build": "tsoa spec-and-routes && tsc",
"dev": "tsoa spec-and-routes && tsc --watch"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^22.10.6",
"@types/swagger-ui-express": "^4.1.7",
"@typescript-eslint/eslint-plugin": "^8.20.0",
"@typescript-eslint/parser": "^8.20.0",
"eslint": "^9.18.0",
"typescript": "~5.6.3"
},
"dependencies": {
"express": "^4.21.2",
"inversify": "^6.2.1",
"inversify-binding-decorators": "^4.0.0",
"swagger-ui-express": "^5.0.1",
"tsoa": "^6.6.0",
"xo-common": "^0.8.0"
}
}

View File

@@ -0,0 +1,30 @@
import { getRestApi } from '../index.js'
import { XapiXoObject } from '../xoApp.type.js'
import { Controller } from 'tsoa'
export abstract class XapiXoController<T extends XapiXoObject> extends Controller {
protected restApi = getRestApi()
protected type
constructor(type: T['type']) {
super()
this.type = type
}
// wrap these methods with permission (filter only objects that the users can see)
protected getObjects(): Record<T['id'], T> | undefined {
return this.restApi.getObjectsByType(this.type) as Record<T['id'], T> | undefined
}
protected getById(id: T['id']): T {
return this.restApi.getObject(id, this.type)
}
protected getXapiObject(objOrId: T['id'] | T) {
return this.restApi.getXapiObject(objOrId, this.type)
}
// --------------------------------------------------------------------------------
protected getObjectIds(): T['id'][] {
return Object.keys(this.getObjects() ?? {})
}
}

View File

@@ -0,0 +1,37 @@
import { getRestApi } from '../index.js'
import { NonXapiObject } from '../xoApp.type.js'
import { Controller } from 'tsoa'
type XoType = 'server'
export abstract class XoController<T extends NonXapiObject> extends Controller {
#restApi = getRestApi()
#type: XoType
#fnByType = {
server: {
getAll: () => this.#restApi.getServers(),
getById: (id: NonXapiObject['id']) => this.#restApi.getServer(id),
},
}
constructor(type: XoType) {
super()
this.#type = type
}
protected getObjects() {
return this.#fnByType[this.#type].getAll()
}
protected getObject(id: T['id']) {
return this.#fnByType[this.#type].getById(id)
}
protected async getObjectIds(): Promise<NonXapiObject['id'][]> {
const objects = await this.getObjects()
if (Array.isArray(objects)) {
return objects.map(obj => obj.id)
}
return Object.keys(objects)
}
}

View File

@@ -0,0 +1,36 @@
import { Controller, Get, Request, Route } from 'tsoa'
import DashboardService from './dashboard.service.js'
import { inject } from 'inversify'
import { provideSingleton } from '../ioc/helper.js'
import { Request as ExReq } from 'express'
export type Dashboard = {
vmsStatus: {
running: number
inactive: number
unknown: number
}
poolsStatus: {
connected: number
unreachable: number
unknown: number
}
}
@Route('dashboard')
@provideSingleton(DashboardController)
export class DashboardController extends Controller {
#dashboardService
constructor(@inject(DashboardService) dashboardService: DashboardService) {
super()
this.#dashboardService = dashboardService
}
@Get()
public async getDashboard(@Request() req: ExReq): Promise<Dashboard> {
const resp = req.res!
resp.setHeader('Access-Control-Allow-Origin', '*') // TODO: remove this. Only used for test
return this.#dashboardService.getDashboard()
}
}

View File

@@ -0,0 +1,107 @@
import { getRestApi } from '../index.js'
import { Dashboard } from './dashboard.controller.js'
import { provideSingleton } from '../ioc/helper.js'
// cache used to compare changes and trigger dashboard events
type CacheDashboard = {
vmsStatus: Dashboard['vmsStatus']
poolsStatus: Dashboard['poolsStatus']
}
const cache = new Map<keyof CacheDashboard, CacheDashboard[keyof CacheDashboard]>()
@provideSingleton(DashboardService)
export default class DashboardService {
#restApi
#fnById = {
// arrow fn to ensure to call the method in the good context
vmsStatus: () => this.#getVmsStatus(),
poolsStatus: () => this.#getPoolsStatus(),
}
constructor() {
this.#restApi = getRestApi()
this.#registerListener()
}
async getDashboard(): Promise<Dashboard> {
const vmsStatus = this.#getVmsStatus()
const poolsStatus = await this.#getPoolsStatus()
return { vmsStatus, poolsStatus }
}
#getVmsStatus(): Dashboard['vmsStatus'] {
const vms = Object.values(this.#restApi.getObjectsByType('VM') ?? {}) as any
let running = 0
let inactive = 0
let unknown = 0
vms.forEach(vm => {
if (vm.power_state === 'Running' || vm.power_state === 'Paused') {
running++
} else if (vm.power_state === 'Halted' || vm.power_state === 'Suspended') {
inactive++
} else {
unknown++
}
})
const result = { running, inactive, unknown }
cache.set('vmsStatus', result)
return result
}
async #getPoolsStatus(): Promise<Dashboard['poolsStatus']> {
const servers = await this.#restApi.getServers()
const poolIds = Object.keys(this.#restApi.getObjectsByType('pool') ?? {})
let nConnectedServers = 0
let nUnreachableServers = 0
let nUnknownServers = 0
servers.forEach(server => {
// it may happen that some servers are marked as "connected", but no pool matches "server.pool"
// so they are counted as `nUnknownServers`
if (server.status === 'connected' && poolIds.includes(server.poolId)) {
nConnectedServers++
return
}
if (
server.status === 'disconnected' &&
server.error !== undefined &&
server.error.connectedServerId === undefined
) {
nUnreachableServers++
return
}
if (server.status === 'disconnected') {
return
}
nUnknownServers++
})
const result = { connected: nConnectedServers, unreachable: nUnreachableServers, unknown: nUnknownServers }
cache.set('poolsStatus', result)
return result
}
async #maybeSendEvent(key: keyof Dashboard) {
const stringifiedCacheValue = JSON.stringify(cache.get(key))
const newValue = await this.#fnById[key]()
if (JSON.stringify(newValue) === stringifiedCacheValue) {
return
}
this.#restApi.sendData(key, 'dashboard', newValue, 'update')
}
#registerListener() {
this.#restApi.ee.on('vm', () => this.#maybeSendEvent('vmsStatus'))
this.#restApi.ee.on('pool', () => this.#maybeSendEvent('poolsStatus'))
// this.#restApi.ee.on('host', () => this.#maybeSendEvent('hostsStatus'))
}
}

View File

@@ -0,0 +1,34 @@
import { Request as ExRequest } from 'express'
import { getRestApi } from '../index.js'
import { Controller, Get, Request, Route } from 'tsoa'
import { provideSingleton } from '../ioc/helper.js'
@Route('events')
@provideSingleton(EventsController)
export class EventsController extends Controller {
/**
* subscribe to all evenements
*/
@Get()
public async getEvent(@Request() req: ExRequest): Promise<void> {
const restApi = getRestApi()
const res = req.res!
res.setHeader('Access-Control-Allow-Origin', '*') // TODO: remove this. Only used for test
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
res.write(`data:${JSON.stringify({ data: null, operation: 'initial' })}\n\n`)
const symbol = Symbol('client-id')
restApi.addSseClient(symbol, res)
res.on('close', () => {
restApi.removeSseClient(symbol)
res.end()
})
}
}

View File

@@ -0,0 +1,134 @@
import swaggerUi from 'swagger-ui-express'
import { Express, NextFunction, Request, Response } from 'express'
import swaggerOpenApiSpec from './open-api/spec/swagger.json' assert { type: 'json' }
import { RegisterRoutes } from './open-api/routes/routes.js'
import { XapiXoObject, XoApp } from './xoApp.type.js'
import { EventEmitter } from 'events'
import DashboardService from './dashboard/dashboard.service.js'
import { iocContainer } from './ioc/ioc.js'
import { errorHandler } from './middleware/error.middleware.js'
import fs from 'node:fs/promises'
import { dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
class RestApi {
#sseClients: Map<symbol, Response> = new Map()
#express
ee = new EventEmitter()
authenticateUser
getObject
getObjects
getServers: XoApp['getAllXenServers']
getServer: XoApp['getXenServer']
getXapiObject: XoApp['getXapiObject']
getObjectsByType
constructor(xoApp: XoApp, express: Express) {
console.log('INITIALIZE')
if (restApi !== undefined) {
throw new Error('RestApi is a singleton')
}
this.#express = express
// existing xo methods
this.authenticateUser = xoApp.authenticateUser.bind(xoApp)
this.getObject = xoApp.getObject.bind(xoApp)
this.getObjects = xoApp.getObjects.bind(xoApp)
this.getServers = () => xoApp.getAllXenServers()
this.getXapiObject = (idOrObj, type) => xoApp.getXapiObject(idOrObj, type)
this.getServer = id => xoApp.getXenServer(id)
// helpers
this.getObjectsByType = <T extends keyof typeof xoApp.objects.indexes.type>(type: T) =>
xoApp.objects.indexes.type[type]
// @ts-ignore
this.#registerListener(xoApp.objects.allIndexes.type)
}
addSseClient(id: symbol, client: Response) {
this.#sseClients.set(id, client)
}
removeSseClient(id: symbol) {
this.#sseClients.delete(id)
}
sendData(objId: string, objType: string | undefined, obj: any | undefined, operation: 'update' | 'add' | 'remove') {
this.#sseClients.forEach(client => {
client.write(`data: ${JSON.stringify({ id: objId, type: objType, data: obj, operation })}\n\n`)
})
}
async registerRoute(
endpoint: string,
cb: (req: Request, res: Response, next: NextFunction) => any,
openapiSpec: Object,
httpMethod: 'get' | 'post' | 'put' | 'patch' | 'delete' = 'get'
) {
if (endpoint[0] !== '/') {
endpoint = '/' + endpoint
}
this.#express[httpMethod](`/rest/v1${endpoint}`, cb)
const currentConfig = JSON.parse(
await fs.readFile(`${__dirname}/open-api/spec/swagger-tmp.json`, { encoding: 'utf-8' })
)
currentConfig.paths[endpoint] = openapiSpec
await fs.writeFile(`${__dirname}/open-api/spec/swagger-tmp.json`, JSON.stringify(currentConfig), {
encoding: 'utf-8',
})
}
#registerListener(obj: EventEmitter) {
// XO events
obj.on('remove', (type, data) => this.#handleXapiEvent(type, data, 'remove'))
obj.on('add', (type, data) => this.#handleXapiEvent(type, data, 'add'))
obj.on('update', (type, data) => this.#handleXapiEvent(type, data, 'update'))
}
async #handleXapiEvent(type: string, data: XapiXoObject, operation: 'update' | 'add' | 'remove') {
if ((data as any).type === 'message') {
// just to avoid client to be spammed by message for the moment
return
}
this.ee.emit(type.toLowerCase(), data, operation)
this.sendData(data.id, data.type, data, operation)
}
}
let restApi: RestApi
export const getRestApi = () => {
if (restApi === undefined) {
throw new Error('The REST API is not instantiated')
}
return restApi
}
export default async function setupRestApi(express: Express, xoApp: XoApp) {
restApi = new RestApi(xoApp, express)
await fs.copyFile(`${__dirname}/open-api/spec/swagger.json`, `${__dirname}/open-api/spec/swagger-tmp.json`)
express.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*') // TODO: remove this. Only used for test
next()
})
express.use('/rest/v1/api-doc', swaggerUi.serve, async (req, res, next) => {
const spec = JSON.parse(await fs.readFile(`${__dirname}/open-api/spec/swagger-tmp.json`, { encoding: 'utf-8' }))
swaggerUi.setup(spec)(req, res, next)
})
RegisterRoutes(express)
express.use(errorHandler)
// in order to create the instance of the service (and start to listen for dashboard changes)
iocContainer.get(DashboardService)
}

View File

@@ -0,0 +1,8 @@
import { interfaces } from 'inversify'
import { fluentProvide, provide } from 'inversify-binding-decorators'
const provideSingleton = function <T>(identifier: interfaces.ServiceIdentifier<T>) {
return fluentProvide(identifier).inSingletonScope().done()
}
export { provideSingleton, provide }

View File

@@ -0,0 +1,11 @@
import { Container, decorate, injectable } from 'inversify'
import { buildProviderModule } from 'inversify-binding-decorators'
import { Controller } from 'tsoa'
const iocContainer = new Container()
decorate(injectable(), Controller)
iocContainer.load(buildProviderModule())
export { iocContainer }

View File

@@ -0,0 +1,39 @@
import * as express from 'express'
import { getRestApi } from '../index.js'
export async function expressAuthentication(
req: express.Request,
securityName: string,
scopes?: string[]
): Promise<any> {
const restApi = getRestApi()
const ip = req.ip
const permission = scopes?.[0]
if (securityName === 'token') {
const token = req.cookies.token ?? req.cookies.authenticationToken
const { user } = await restApi.authenticateUser({ token }, { ip })
if (permission !== undefined && permission !== user.permission) {
return Promise.reject(new Error('unauthorize'))
}
return Promise.resolve(user)
}
if (securityName === 'basic_auth') {
const authorization = req.headers.authorization ?? ''
const [, encodedCredentials] = authorization.split(' ')
if (encodedCredentials === undefined) {
return Promise.reject(new Error('missing credentials'))
}
const [username, password] = Buffer.from(encodedCredentials, 'base64').toString().split(':')
const { user } = await restApi.authenticateUser({ username, password }, { ip })
if (permission !== undefined && permission !== user.permission) {
return Promise.reject(new Error('unauthorize'))
}
return Promise.resolve(user)
}
throw new Error('unimplemented')
}

View File

@@ -0,0 +1,15 @@
import { noSuchObject } from 'xo-common/api-errors.js'
import { NextFunction, Request, Response } from 'express'
export function errorHandler(err: Error, _req: Request, res: Response, next: NextFunction) {
console.error(err) // use log.error instead
if (noSuchObject.is(err)) {
res.status(404).json({ error: err.message })
return next()
}
res.json({ error: err.message })
next()
}

View File

@@ -0,0 +1,24 @@
import { Get, Path, Route, Security } from 'tsoa'
import { XoServer } from './server.type.js'
import { provideSingleton } from '../ioc/helper.js'
import { XoController } from '../abstract/xo.controller.js'
@Route('servers')
@provideSingleton(ServersController)
export class ServersController extends XoController<XoServer> {
constructor() {
super('server')
}
@Security('token', ['admin'])
@Get()
public getServers(): Promise<string[]> {
return this.getObjectIds()
}
@Security('basic_auth', ['admin'])
@Get('{id}')
public getServer(@Path() id: XoServer['id']): Promise<XoServer> {
return this.getObject(id)
}
}

View File

@@ -0,0 +1,14 @@
import { NonXapiObject } from '../xoApp.type.js'
export interface XoServer extends NonXapiObject {
allowUnauthorized: boolean
host: string
label: string
password: string
username: string
enabled: boolean
readOnly: boolean
status: 'connected' | 'disconnected'
error?: any
poolId: string /* XoPool['id'] */
}

View File

@@ -0,0 +1,57 @@
import { FormField, Get, Middlewares, Path, Post, Put, Query, Request, Response, Route, UploadedFile } from 'tsoa'
import { Request as ExRequest } from 'express'
import { provideSingleton } from '../ioc/helper.js'
import { XapiXoController } from '../abstract/xapi-xo.controller.js'
/**
* Return a task ID or T if ?watch=true
*/
type WatchAction<T> = string | T
@Route('srs')
@provideSingleton(SrsController)
export class SrsController extends XapiXoController<any> {
constructor() {
super('SR')
}
/**
* Some description
*/
@Get()
@Response(401, 'unautorhized')
public getSrs(): string[] {
return this.getObjectIds()
}
@Get('{id}')
@Response(404, 'Not found')
@Response(401, 'unautorhized')
public getSr(@Path() id: string) {
return this.getById(id)
}
@Post('{id}/actions/import_vdi')
public async importVdi(
@Request() req: ExRequest & { length: number },
@Path() id: string,
@Query() name_label: string,
@Query() name_description: string,
@Query() format: 'raw' | 'vhd'
): Promise<WatchAction<string>> {
// create task and add the possibility to watch/await the task
const sr = this.restApi.getXapiObject(id, this.type) as any
const length = req.headers['content-length']
if (length !== undefined) {
req.length = Number(length)
}
const vdiRef = await sr.$importVdi(req, {
format,
name_label,
name_description,
})
const vdiUUid = await sr.$xapi.getField('VDI', vdiRef, 'uuid')
return vdiUUid
}
}

View File

@@ -0,0 +1,50 @@
import { FormField, Get, Middlewares, Path, Post, Put, Query, Request, Res, Response, Route, UploadedFile } from 'tsoa'
import { Request as ExRequest, Response as ExResp } from 'express'
import { provideSingleton } from '../ioc/helper.js'
import { XapiXoController } from '../abstract/xapi-xo.controller.js'
import { pipeline } from 'node:stream/promises'
@Route('vdis')
@provideSingleton(VdiController)
export class VdiController extends XapiXoController<any> {
constructor() {
super('VDI')
}
/**
* Some description
*/
@Get()
@Response(401, 'unautorhized')
public getVdis(): string[] {
return this.getObjectIds()
}
@Get('{id}.{format}')
public async exportVdi(
@Request() req: ExRequest,
@Path() id: string,
@Path() format: 'vhd' | 'raw',
@Query() nbdConcurrency: number,
@Query() perferNbd = false
) {
const res = req.res!
const vdi = this.getXapiObject(id) as any
const stream = await vdi.$exportContent({ format, perferNbd, nbdConcurrency })
const headers = { 'content-disposition': 'attachment', 'content-length': undefined }
const { length } = stream
if (length !== undefined) {
headers['content-length'] = length
}
res.writeHead(200, 'OK', headers)
await pipeline(stream, res)
}
@Get('{id}')
@Response(404, 'Not found')
@Response(401, 'unautorhized')
public getVdi(@Path() id: string) {
return this.getById(id)
}
}

View File

@@ -0,0 +1,50 @@
import { Get, Path, Post, Query, Response, Route } from 'tsoa'
import { XapiVm, XoVm } from './vm.type.js'
import { provideSingleton } from '../ioc/helper.js'
import { XapiXoController } from '../abstract/xapi-xo.controller.js'
interface ApiError {
error: string
}
@Route('vms')
@provideSingleton(VmsController)
export class VmsController extends XapiXoController<XoVm> {
constructor() {
super('VM')
}
/**
* Some description
*/
@Get()
@Response<ApiError>(401, 'unautorhized')
public getVms(): XoVm['id'][] {
return this.getObjectIds()
}
@Get('{id}')
@Response<ApiError>(404, 'Not found')
@Response<ApiError>(401, 'unautorhized')
public getVm(@Path() id: XoVm['id']) {
return this.getById(id)
}
@Post('{id}/actions/start')
@Response(404, 'Not found')
@Response<ApiError>(401, 'unautorhized')
public startVm(@Path() id: XoVm['id']): void {
const vm = this.restApi.getXapiObject(id, this.type) as XapiVm
// create task and add the possibility to watch/await the task
vm.$callAsync<void>('start', false, false).catch(() => {})
}
@Post('{id}/actions/shutdown')
@Response(404, 'Not found')
@Response<ApiError>(401, 'unautorhized')
public stopVm(@Path() id: XoVm['id'], @Query() hard?: boolean): void {
const vm = this.restApi.getXapiObject(id, this.type) as XapiVm
// create task and add the possibility to watch/await the task
vm.$callAsync<void>(`${hard ? 'hard' : 'clean'}_shutdown`).catch(() => {})
}
}

View File

@@ -0,0 +1,15 @@
import { XapiObject, XapiXoObject } from '../xoApp.type.js'
export interface XapiVm extends XapiObject {
uuid: string
name_label: string
power_state: 'Running' | 'Paused' | 'Halted' | 'Suspended'
}
export interface XoVm extends XapiXoObject {
type: 'VM'
name_label: XapiVm['name_label']
uuid: XapiVm['uuid']
power_state: XapiVm['power_state']
}

View File

@@ -0,0 +1,71 @@
import EventEmitter from 'events'
import { XoVm } from './vms/vm.type.js'
import { Response } from 'express'
import { XoServer } from './servers/server.type.js'
// -----
interface XoPool extends XapiXoObject {
id: string
type: 'pool'
}
interface XoUser extends NonXapiObject {
id: string
permission: string
}
// -----
export type XapiObject = {
$ref: string
$id: string
$type: 'VM' | 'pool'
$xapi: any
$call: <T>(method: string, ...args: any) => Promise<T>
$callAsync: <T>(method: string, ...args: any) => Promise<T>
}
/**
* XapiXoObject can be every "xapi-to-xo" object
*/
export type XapiXoObject = {
id: string
type: XapiObject['$type']
}
/**
* Every object that is not aa XapiXoObject
*/
export type NonXapiObject = {
id: string
}
/**
* Can be every type of object handled by XO
*/
export type XoObject = XapiXoObject | NonXapiObject
interface Objects<T extends XapiXoObject> extends EventEmitter {
indexes: {
type: {
[K in T['type']]: Record<T['id'], Extract<T, { type: K }>> | undefined
}
}
}
export interface XoApp extends EventEmitter {
authenticateUser: (
params: { token?: string; username?: string; password?: string },
optional?: { ip?: string }
) => Promise<{ bypassOtp: boolean; user: XoUser }>
objects: Objects<XoVm | XoPool> /** Nedd to add all XapiXoType here */
getObjects: (opts?: { filter?: (obj: XapiXoObject) => boolean }) => Record<XapiXoObject['id'], XapiXoObject>
getObject: <T extends XapiXoObject>(id: T['id'], type: T['type']) => T
// how to return the right type for getXapiObject?
getXapiObject: <T extends XapiObject>(objOrId: XapiXoObject['id'] | XapiXoObject, type: XapiXoObject['type']) => T
getAllXenServers: () => Promise<XoServer[]>
getXenServer: (id: XoServer['id']) => Promise<XoServer>
addSseClient: (id: symbol, client: Response) => void
removeSseClient: (id: symbol) => void
}

View File

@@ -0,0 +1,39 @@
{
"compilerOptions": {
/* Basic Options */
"incremental": true,
"target": "ESNext",
"module": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"allowJs": true,
/* Strict Type-Checking Options */
"strict": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"noImplicitAny": false,
"alwaysStrict": true,
/* Additional Checks */
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
/* Module Resolution Options */
"baseUrl": "./",
"esModuleInterop": true,
/* Experimental Options */
"experimentalDecorators": true,
/* Advanced Options */
"moduleResolution": "nodenext",
"resolveJsonModule": true
},
"include": ["./src/**/*"]
}

View File

@@ -0,0 +1,49 @@
{
"entryFile": "./src/index.ts",
"noImplicitAdditionalProperties": "throw-on-extras",
"controllerPathGlobs": ["./src/**/*.controller.ts"],
"spec": {
"outputDirectory": "./src/open-api/spec",
"specVersion": 3,
"basePath": "/rest/v1",
"securityDefinitions": {
"basic_auth": {
"type": "http",
"scheme": "basic"
},
"token": {
"type": "apiKey",
"name": "authenticationToken",
"in": "cookie"
}
},
"specMerging": "recursive",
"spec": {
"paths": {
"/srs/{id}/actions/import_vdi": {
"post": {
"requestBody": {
"required": true,
"content": {
"application/octet-stream": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
}
}
}
}
}
},
"routes": {
"routesDir": "./src/open-api/routes",
"esm": true,
"middleware": "express",
"basePath": "/rest/v1",
"iocModule": "./src/ioc/ioc.ts",
"authenticationModule": "./src/middleware/authentication.ts"
}
}

View File

@@ -110,6 +110,10 @@ export class Collection extends EventEmitter {
return this._size
}
get allIndexes() {
return this._indexes
}
// -----------------------------------------------------------------
// Manipulation
// -----------------------------------------------------------------

View File

@@ -4,11 +4,12 @@ import clearObject from './clear-object'
import isEmpty from './is-empty'
import NotImplemented from './not-implemented'
import { ACTION_ADD, ACTION_UPDATE, ACTION_REMOVE } from './collection'
import { EventEmitter } from 'node:events'
// ===================================================================
export class Index {
export class Index extends EventEmitter {
constructor(computeHash) {
super()
if (computeHash) {
this.computeHash = iteratee(computeHash)
}
@@ -85,6 +86,7 @@ export class Index {
(itemsByHash[hash] = {}))[key] = value
keysToHash[key] = hash
this.emit('add', hash, value)
}
}
}
@@ -108,6 +110,7 @@ export class Index {
(itemsByHash[hash] = {}))[key] = value
keysToHash[key] = hash
this.emit('update', hash, value)
} else {
delete keysToHash[key]
}
@@ -120,6 +123,7 @@ export class Index {
for (const key in items) {
const prev = keysToHash[key]
if (prev != null) {
this.emit('remove', prev, itemsByHash[prev][key])
delete itemsByHash[prev][key]
delete keysToHash[key]
}

View File

@@ -54,6 +54,7 @@
"@xen-orchestra/log": "^0.7.1",
"@xen-orchestra/mixin": "^0.2.0",
"@xen-orchestra/mixins": "^0.16.2",
"@xen-orchestra/rest-api": "^0.1.0",
"@xen-orchestra/self-signed": "^0.2.1",
"@xen-orchestra/template": "^0.1.0",
"@xen-orchestra/vmware-explorer": "^0.8.5",

View File

@@ -52,6 +52,7 @@ import transportConsole from '@xen-orchestra/log/transports/console'
import { configure } from '@xen-orchestra/log/configure'
import { generateToken } from './utils.mjs'
import { ProxyAgent } from 'proxy-agent'
import { getRestApi } from '@xen-orchestra/rest-api'
// ===================================================================
@@ -117,7 +118,11 @@ async function createExpressApp(config) {
app.use(helmet(config.http.helmet))
app.use(compression())
app.use(
compression({
filter: req => req.url !== '/rest/v1/events', // compression not compatible with EventSource?? it supposed to work with eventsource@3
})
)
let { sessionSecret } = config.http
if (sessionSecret === undefined) {
@@ -920,6 +925,51 @@ export default async function main(args) {
if (!safeMode) {
await registerPlugins(xo)
// test to register a route on the fly
await getRestApi().registerRoute(
'netbox/sync',
(req, res) => {
res.send('route registered')
},
{
get: {
operationId: 'getSyncNetbox',
responses: {
200: {
description: 'Ok',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Dashboard',
},
},
},
},
},
security: [],
parameters: [],
},
}
)
await getRestApi().registerRoute(
'/foo/bar',
(req, res) => {
res.send('fo bar')
},
{
get: {
operationId: 'getFoobar',
responses: {
200: {
description: 'Ok',
},
},
security: [],
parameters: [],
},
}
)
xo.emit('plugins:registered')
}

View File

@@ -0,0 +1,11 @@
import setupRestApi from '@xen-orchestra/rest-api'
export default class ClassRestApiPoc {
constructor(app, { express }) {
if (express === undefined) {
return
}
setupRestApi(express, app)
}
}

749
yarn.lock

File diff suppressed because it is too large Load Diff