blob: 83dd6cfde0f111a2f23ed958b094b04bdf977c03 [file] [log] [blame] [edit]
import { useQuery } from '@tanstack/vue-query';
import { computed } from 'vue';
import api from '@/store/api';
import { useRedfishRoot, supportsExpandQuery } from './useRedfishRoot';
/**
* Redfish collection member reference
*/
export interface CollectionMember {
'@odata.id': string;
}
/**
* Redfish collection response
*/
export interface RedfishCollection<T = unknown> {
'@odata.id': string;
'@odata.type': string;
Name: string;
Members: T[];
'Members@odata.count': number;
}
/**
* OData Query Parameters for Redfish API
*/
export interface RedfishQueryParameters {
$expand?:
| string
| {
$levels?: number;
$noLinks?: boolean;
$expandAll?: boolean;
$links?: string;
};
$filter?: string;
$select?: string | string[];
$top?: number;
$skip?: number;
only?: boolean;
excerpt?: boolean;
}
/**
* Options for fetching a Redfish collection
*/
export interface FetchCollectionOptions {
expand?: boolean;
expandLevels?: number;
select?: string[];
filter?: string;
}
/**
* Builds a Redfish API URL with OData query parameters
*
* Handles proper encoding and formatting of OData directives:
* - $expand with nested options like .($levels=2)
* - $select with multiple properties
* - $filter, $top, $skip for pagination and filtering
* - Custom Redfish parameters like 'only' and 'excerpt'
*
* @param path - Base path (e.g., '/redfish/v1/Chassis')
* @param params - OData query parameters
* @returns Complete URL with query string
*
* @example
* buildQuery('/redfish/v1/Chassis', { $expand: '*' })
* // Returns: '/redfish/v1/Chassis?$expand=*'
*
* @example
* buildQuery('/redfish/v1/Systems', {
* $expand: { $levels: 2, $noLinks: true }
* })
* // Returns: '/redfish/v1/Systems?$expand=.($levels=2;$noLinks=true)'
*/
export function buildQuery(
path: string,
params?: RedfishQueryParameters,
): string {
if (!params) return path;
const pairs: string[] = [];
// Handle $expand parameter
if (params.$expand) {
if (typeof params.$expand === 'string') {
// Simple string expand (e.g., '*' or 'Members')
// Do not encode $ directives inside the value
pairs.push(`$expand=${params.$expand}`);
} else {
// Complex expand with options
const expandParts: string[] = [];
if (params.$expand.$levels !== undefined) {
expandParts.push(`$levels=${params.$expand.$levels}`);
}
if (params.$expand.$noLinks !== undefined) {
expandParts.push(`$noLinks=${params.$expand.$noLinks}`);
}
if (params.$expand.$expandAll !== undefined) {
expandParts.push(`$expandAll=${params.$expand.$expandAll}`);
}
if (params.$expand.$links !== undefined) {
expandParts.push(`$links=${params.$expand.$links}`);
}
// Build .(options) without encoding the $ directives
// Use ';' between options per OData specification
const opts = expandParts.join(';');
pairs.push(`$expand=.(${opts})`);
}
}
// Handle $filter parameter
if (params.$filter) {
pairs.push(`$filter=${encodeURIComponent(params.$filter)}`);
}
// Handle $select parameter
if (params.$select) {
const sel = Array.isArray(params.$select)
? params.$select.join(',')
: params.$select;
pairs.push(`$select=${encodeURIComponent(sel)}`);
}
// Handle $top parameter (pagination)
if (params.$top !== undefined) {
pairs.push(`$top=${encodeURIComponent(String(params.$top))}`);
}
// Handle $skip parameter (pagination)
if (params.$skip !== undefined) {
pairs.push(`$skip=${encodeURIComponent(String(params.$skip))}`);
}
// Handle 'only' parameter (Redfish-specific)
if (params.only) {
pairs.push('only=');
}
// Handle 'excerpt' parameter (Redfish-specific)
if (params.excerpt !== undefined) {
pairs.push(`excerpt=${encodeURIComponent(String(params.excerpt))}`);
}
const qs = pairs.join('&');
return qs ? `${path}?${qs}` : path;
}
/**
* Normalizes Redfish query parameters for cache stability
*
* Ensures consistent query keys by:
* - Sorting array values (like $select)
* - Freezing the result to prevent mutations
* - Handling undefined values consistently
*
* @param params - Query parameters to normalize
* @returns Normalized and frozen parameters, or undefined if input is undefined
*/
function normalizeRedfishQueryParameters(
params?: RedfishQueryParameters,
): Readonly<RedfishQueryParameters> | undefined {
if (!params) return undefined;
const normalizedSelect =
params.$select === undefined
? undefined
: Array.isArray(params.$select)
? [...params.$select].sort()
: params.$select;
const normalizedExpand =
params.$expand === undefined
? undefined
: typeof params.$expand === 'string'
? params.$expand
: {
$levels: params.$expand.$levels,
$noLinks: params.$expand.$noLinks,
$expandAll: params.$expand.$expandAll,
$links: params.$expand.$links,
};
return Object.freeze({
$expand: normalizedExpand,
$filter: params.$filter,
$select: normalizedSelect,
$top: params.$top,
$skip: params.$skip,
only: params.only,
excerpt: params.excerpt,
});
}
/**
* Fetches a Redfish collection with optional OData query parameters
* Gracefully falls back if BMC doesn't support OData features
*
* @param path - Collection path (e.g., '/redfish/v1/Chassis')
* @param options - Fetch options
* @param supportsExpand - Whether BMC supports $expand
* @returns Promise with collection data
*/
async function fetchCollection<T>(
path: string,
options: FetchCollectionOptions,
supportsExpand: boolean,
): Promise<T[]> {
const { expand, expandLevels = 1, select, filter } = options;
// Build query parameters using the reusable buildQuery function
const queryParams: RedfishQueryParameters = {};
if (expand && supportsExpand) {
queryParams.$expand = { $levels: expandLevels };
}
if (select && select.length > 0) {
queryParams.$select = select;
}
if (filter) {
queryParams.$filter = filter;
}
const url = buildQuery(path, queryParams);
try {
const { data } = await api.get<RedfishCollection<T>>(url);
if (expand && supportsExpand && data.Members) {
return data.Members;
}
if (data.Members && Array.isArray(data.Members)) {
const memberPromises = data.Members.map((member: CollectionMember) =>
api
.get<T>(member['@odata.id'])
.then((res: { data: T }) => res.data)
.catch((error: Object) => {
console.error(
`Error fetching member ${member['@odata.id']}:`,
error,
);
return null;
}),
);
const members = await Promise.all(memberPromises);
return members.filter((m: T | null): m is T => m !== null);
}
return [];
} catch (error) {
// If OData query failed, try without parameters
const hasQueryParams = url !== path;
if (hasQueryParams) {
console.warn(
`OData query failed for ${path}, falling back to basic fetch`,
);
try {
const { data } =
await api.get<RedfishCollection<CollectionMember>>(path);
if (data.Members && Array.isArray(data.Members)) {
const memberPromises = data.Members.map((member: CollectionMember) =>
api
.get<T>(member['@odata.id'])
.then((res: { data: T }) => res.data)
.catch((err: Object) => {
console.error(
`Error fetching member ${member['@odata.id']}:`,
err,
);
return null;
}),
);
const members = await Promise.all(memberPromises);
return members.filter((m: T | null): m is T => m !== null);
}
} catch (fallbackError) {
console.error(`Failed to fetch collection ${path}:`, fallbackError);
throw fallbackError;
}
}
console.error(`Failed to fetch collection ${path}:`, error);
throw error;
}
}
/**
* TanStack Query hook for fetching a Redfish collection
*
* @param path - Collection path
* @param options - Fetch options
* @returns TanStack Query result
*/
export function useRedfishCollection<T>(
path: string,
options: FetchCollectionOptions = {},
) {
// Get ServiceRoot to check OData support
const { data: serviceRoot } = useRedfishRoot();
// Compute whether expand is supported
const canExpand = computed(() => supportsExpandQuery(serviceRoot.value));
// Build query parameters for normalization
const queryParams: RedfishQueryParameters = {};
if (options.expand) {
queryParams.$expand = { $levels: options.expandLevels || 1 };
}
if (options.select && options.select.length > 0) {
queryParams.$select = options.select;
}
if (options.filter) {
queryParams.$filter = options.filter;
}
// Normalize query parameters for stable cache keys
const normalizedParams = normalizeRedfishQueryParameters(queryParams);
return useQuery({
queryKey: ['redfish', 'collection', path, normalizedParams],
queryFn: () => fetchCollection<T>(path, options, canExpand.value),
enabled: computed(() => !!serviceRoot.value),
refetchOnMount: false, // Don't refetch when component remounts
refetchOnWindowFocus: false, // Don't refetch when window regains focus
refetchOnReconnect: false,
retry: 2,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
});
}