mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-09 17:06:13 -06:00
346 lines
9.8 KiB
JavaScript
346 lines
9.8 KiB
JavaScript
import { createLogger } from '@xen-orchestra/log'
|
|
import { FilterBuilder } from './FilterBuilder.js'
|
|
|
|
const log = createLogger('rest')
|
|
|
|
/**
|
|
* REST API client for XenOrchestra HTTP-based operations.
|
|
*
|
|
* This class provides a basic interface to XenOrchestra's REST API,
|
|
* handling authentication and basic request management.
|
|
*
|
|
* @class RestApiClient
|
|
*/
|
|
export class RestApiClient {
|
|
/**
|
|
* Private configuration object
|
|
* @type {Object}
|
|
* @private
|
|
*/
|
|
#config
|
|
|
|
/**
|
|
* Base URL for REST API requests
|
|
* @type {string}
|
|
*/
|
|
baseUrl
|
|
|
|
/**
|
|
* Authentication token
|
|
* @type {string}
|
|
*/
|
|
token = ''
|
|
|
|
/**
|
|
* HTTP headers for requests
|
|
* @type {Object}
|
|
*/
|
|
headers = {}
|
|
|
|
/**
|
|
* Creates a new RestApiClient instance.
|
|
*
|
|
* @param {Object} config - Configuration object
|
|
* @param {string} config.xoUrl - XenOrchestra server URL (e.g., 'https://xo.example.com')
|
|
* @param {string} config.username - Username for authentication
|
|
* @param {string} config.password - Password for authentication
|
|
* @throws {Error} If configuration is invalid
|
|
*/
|
|
constructor(config) {
|
|
if (!config || typeof config !== 'object') {
|
|
throw new Error('Configuration object is required')
|
|
}
|
|
|
|
if (!config.xoUrl || !config.username || !config.password) {
|
|
throw new Error('Missing required configuration: xoUrl, username, and password are required')
|
|
}
|
|
|
|
this.#config = config
|
|
this.baseUrl = this.#config.xoUrl.replace(/\/+$/, '')
|
|
this.token = ''
|
|
this.headers = {}
|
|
}
|
|
|
|
/**
|
|
* Updates authentication headers with the provided token.
|
|
*
|
|
* @param {string} token - Authentication token received from XenOrchestra
|
|
* @throws {Error} If token is not a valid string
|
|
* @private
|
|
*/
|
|
updateHeaders(token) {
|
|
this.token = token
|
|
this.headers = {
|
|
'Content-Type': 'application/json',
|
|
Cookie: `token=${this.token}`,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Establishes connection and authenticates with XenOrchestra REST API.
|
|
*
|
|
* @returns {Promise<void>} Resolves when authentication is successful
|
|
* @throws {Error} If authentication fails or network error occurs
|
|
*/
|
|
async connect() {
|
|
const { username, password } = this.#config
|
|
|
|
try {
|
|
// Prepare Basic authentication
|
|
const auth = `${username}:${password}`
|
|
const options = {
|
|
method: 'post',
|
|
headers: {
|
|
Authorization: `Basic ${Buffer.from(auth).toString('base64')}`,
|
|
},
|
|
}
|
|
|
|
const url = `${this.baseUrl}/rest/v0/users/me/authentication_tokens`
|
|
const response = await fetch(url, options)
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text()
|
|
throw new Error(`Authentication failed (${response.status}): ${errorText}`)
|
|
}
|
|
|
|
const jsonResp = await response.json()
|
|
|
|
if (!jsonResp.token || !jsonResp.token.id) {
|
|
throw new Error('Invalid response: missing authentication token')
|
|
}
|
|
|
|
this.updateHeaders(jsonResp.token.id)
|
|
log.debug('Successfully authenticated with XenOrchestra REST API')
|
|
} catch (error) {
|
|
log.warn('Failed to connect to XO REST API', { error })
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Executes a fetch call, re-authenticates and retries once on 401.
|
|
* A 401 can happen transiently when a Redis-backed token lookup fails;
|
|
* re-connecting creates a fresh token that succeeds on the next attempt.
|
|
*
|
|
* @param {Function} fetchFn - () => Promise<Response>
|
|
* @returns {Promise<Response>}
|
|
* @private
|
|
*/
|
|
async #executeWithRetry(fetchFn) {
|
|
const response = await fetchFn()
|
|
if (response.status !== 401) {
|
|
return response
|
|
}
|
|
log.debug('Received 401, re-authenticating and retrying once')
|
|
await this.connect()
|
|
return fetchFn()
|
|
}
|
|
|
|
/**
|
|
* Builds the error of a failed response, keeping its status and body so callers
|
|
* can react to a specific status (e.g. 404) and log the reason sent by XO.
|
|
*
|
|
* @param {Response} response - Non-ok fetch response
|
|
* @returns {Promise<Error & {status: number, body: string}>}
|
|
* @private
|
|
*/
|
|
static async #httpError(response) {
|
|
const error = new Error(`HTTP ${response.status}: ${response.statusText}`)
|
|
error.status = response.status
|
|
error.body = await response.text().catch(() => '')
|
|
return error
|
|
}
|
|
|
|
/**
|
|
* Basic GET request method.
|
|
*
|
|
* @param {string} endpoint - API endpoint path
|
|
* @returns {Promise<Object>} Response from the API
|
|
* @throws {Error} If the request fails or client is not connected
|
|
*/
|
|
async get(endpoint) {
|
|
try {
|
|
const url = `${this.baseUrl}${endpoint}`
|
|
const response = await this.#executeWithRetry(() =>
|
|
fetch(url, {
|
|
method: 'GET',
|
|
headers: this.headers,
|
|
})
|
|
)
|
|
|
|
if (response.ok) {
|
|
const text = await response.text()
|
|
return text ? JSON.parse(text) : {}
|
|
}
|
|
throw await RestApiClient.#httpError(response)
|
|
} catch (error) {
|
|
log.warn('GET request failed on ' + endpoint, { error })
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Private helper for HTTP requests with body (POST/PUT/PATCH).
|
|
*
|
|
* @param {string} method - HTTP method (POST, PUT, PATCH)
|
|
* @param {string} endpoint - API endpoint path
|
|
* @param {Object} data - Request body data
|
|
* @returns {Promise<Object>} Response from the API
|
|
* @throws {Error} If the request fails
|
|
* @private
|
|
*/
|
|
async _requestWithBody(method, endpoint, data) {
|
|
try {
|
|
const url = `${this.baseUrl}${endpoint}`
|
|
const body = JSON.stringify(data)
|
|
const response = await this.#executeWithRetry(() =>
|
|
fetch(url, {
|
|
method,
|
|
headers: this.headers,
|
|
body,
|
|
})
|
|
)
|
|
|
|
if (response.ok) {
|
|
const text = await response.text()
|
|
return text ? JSON.parse(text) : {}
|
|
}
|
|
throw await RestApiClient.#httpError(response)
|
|
} catch (error) {
|
|
log.warn(`${method} request failed`, { error })
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Basic POST request method.
|
|
*
|
|
* @param {string} endpoint - API endpoint path
|
|
* @param {Object} data - Request body data
|
|
* @returns {Promise<Object>} Response from the API
|
|
* @throws {Error} If the request fails or client is not connected
|
|
*/
|
|
async post(endpoint, data) {
|
|
return this._requestWithBody('POST', endpoint, data)
|
|
}
|
|
|
|
/**
|
|
* Basic PUT request method.
|
|
*
|
|
* @param {string} endpoint - API endpoint path
|
|
* @param {Object} data - Request body data
|
|
* @returns {Promise<Object>} Response from the API
|
|
* @throws {Error} If the request fails or client is not connected
|
|
*/
|
|
async put(endpoint, data) {
|
|
return this._requestWithBody('PUT', endpoint, data)
|
|
}
|
|
|
|
/**
|
|
* Basic PATCH request method.
|
|
*
|
|
* @param {string} endpoint - API endpoint path
|
|
* @param {Object} data - Request body data
|
|
* @returns {Promise<Object>} Response from the API
|
|
* @throws {Error} If the request fails or client is not connected
|
|
*/
|
|
async patch(endpoint, data) {
|
|
return this._requestWithBody('PATCH', endpoint, data)
|
|
}
|
|
|
|
/**
|
|
* Basic DELETE request method.
|
|
*
|
|
* @param {string} endpoint - API endpoint path
|
|
* @returns {Promise<Object>} Response from the API
|
|
* @throws {Error} If the request fails or client is not connected
|
|
*/
|
|
async delete(endpoint) {
|
|
try {
|
|
const url = `${this.baseUrl}${endpoint}`
|
|
const response = await this.#executeWithRetry(() =>
|
|
fetch(url, {
|
|
method: 'DELETE',
|
|
headers: this.headers,
|
|
})
|
|
)
|
|
|
|
if (response.ok) {
|
|
// Some DELETE endpoints return empty response
|
|
const text = await response.text()
|
|
return text ? JSON.parse(text) : {}
|
|
}
|
|
throw await RestApiClient.#httpError(response)
|
|
} catch (error) {
|
|
log.warn('DELETE request failed', { error })
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Builds query string for getObjects requests.
|
|
* @param {Object|FilterBuilder} [options] - Filter options or FilterBuilder instance
|
|
* @returns {string} Query string with fields=* ensured
|
|
* @private
|
|
*/
|
|
static buildGetObjectsQuery(options = {}) {
|
|
let queryString = ''
|
|
|
|
if (options instanceof FilterBuilder) {
|
|
queryString = options.hasContent() ? options.build() : ''
|
|
} else if (options && typeof options === 'object' && Object.keys(options).length > 0) {
|
|
const builder = FilterBuilder.create()
|
|
|
|
if (options.filter) builder.fromObject(options.filter)
|
|
if (options.fields) builder.selectFields(options.fields)
|
|
if (options.additionalParams) {
|
|
Object.entries(options.additionalParams).forEach(([key, value]) => builder.withParam(key, value))
|
|
}
|
|
|
|
queryString = builder.build()
|
|
}
|
|
|
|
// Ensure fields=* is included
|
|
if (!queryString) return 'fields=*'
|
|
if (!queryString.includes('fields=')) queryString += '&fields=*'
|
|
|
|
return queryString
|
|
}
|
|
|
|
/**
|
|
* Gets full objects from REST API endpoints that return URLs.
|
|
*
|
|
* @param {string} endpoint - API endpoint path
|
|
* @param {Object|FilterBuilder} [options] - Filter options or FilterBuilder instance
|
|
* @param {Object} [options.filter] - Filter criteria as key-value pairs
|
|
* @param {string|string[]} [options.fields] - Fields to select (* for all)
|
|
* @param {Object} [options.additionalParams] - Additional query parameters
|
|
* @returns {Promise<Array>} Array of full objects
|
|
* @throws {Error} If the request fails
|
|
*/
|
|
async getObjects(endpoint, options = {}) {
|
|
// Use helper to build query string
|
|
const queryString = RestApiClient.buildGetObjectsQuery(options)
|
|
|
|
// Build final URL and make HTTP request
|
|
const finalEndpoint = `${endpoint}${endpoint.includes('?') ? '&' : '?'}${queryString}`
|
|
const response = await this.get(finalEndpoint)
|
|
|
|
// Handle response
|
|
if (!Array.isArray(response)) {
|
|
return []
|
|
}
|
|
|
|
return response
|
|
}
|
|
|
|
/**
|
|
* Checks if the client is connected.
|
|
*
|
|
* @returns {boolean} True if connected and authenticated
|
|
*/
|
|
isConnected() {
|
|
return !!this.token
|
|
}
|
|
}
|