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 { '@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 | 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( path: string, options: FetchCollectionOptions, supportsExpand: boolean, ): Promise { 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>(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(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>(path); if (data.Members && Array.isArray(data.Members)) { const memberPromises = data.Members.map((member: CollectionMember) => api .get(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( 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(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), }); }