1import { useQuery } from '@tanstack/vue-query'; 2import { computed } from 'vue'; 3import api from '@/store/api'; 4import { useRedfishRoot, supportsExpandQuery } from './useRedfishRoot'; 5 6/** 7 * Redfish collection member reference 8 */ 9export interface CollectionMember { 10 '@odata.id': string; 11} 12 13/** 14 * Redfish collection response 15 */ 16export interface RedfishCollection<T = unknown> { 17 '@odata.id': string; 18 '@odata.type': string; 19 Name: string; 20 Members: T[]; 21 'Members@odata.count': number; 22} 23 24/** 25 * OData Query Parameters for Redfish API 26 */ 27export interface RedfishQueryParameters { 28 $expand?: 29 | string 30 | { 31 $levels?: number; 32 $noLinks?: boolean; 33 $expandAll?: boolean; 34 $links?: string; 35 }; 36 $filter?: string; 37 $select?: string | string[]; 38 $top?: number; 39 $skip?: number; 40 only?: boolean; 41 excerpt?: boolean; 42} 43 44/** 45 * Options for fetching a Redfish collection 46 */ 47export interface FetchCollectionOptions { 48 expand?: boolean; 49 expandLevels?: number; 50 select?: string[]; 51 filter?: string; 52} 53 54/** 55 * Builds a Redfish API URL with OData query parameters 56 * 57 * Handles proper encoding and formatting of OData directives: 58 * - $expand with nested options like .($levels=2) 59 * - $select with multiple properties 60 * - $filter, $top, $skip for pagination and filtering 61 * - Custom Redfish parameters like 'only' and 'excerpt' 62 * 63 * @param path - Base path (e.g., '/redfish/v1/Chassis') 64 * @param params - OData query parameters 65 * @returns Complete URL with query string 66 * 67 * @example 68 * buildQuery('/redfish/v1/Chassis', { $expand: '*' }) 69 * // Returns: '/redfish/v1/Chassis?$expand=*' 70 * 71 * @example 72 * buildQuery('/redfish/v1/Systems', { 73 * $expand: { $levels: 2, $noLinks: true } 74 * }) 75 * // Returns: '/redfish/v1/Systems?$expand=.($levels=2;$noLinks=true)' 76 */ 77export function buildQuery( 78 path: string, 79 params?: RedfishQueryParameters, 80): string { 81 if (!params) return path; 82 83 const pairs: string[] = []; 84 85 // Handle $expand parameter 86 if (params.$expand) { 87 if (typeof params.$expand === 'string') { 88 // Simple string expand (e.g., '*' or 'Members') 89 // Do not encode $ directives inside the value 90 pairs.push(`$expand=${params.$expand}`); 91 } else { 92 // Complex expand with options 93 const expandParts: string[] = []; 94 95 if (params.$expand.$levels !== undefined) { 96 expandParts.push(`$levels=${params.$expand.$levels}`); 97 } 98 if (params.$expand.$noLinks !== undefined) { 99 expandParts.push(`$noLinks=${params.$expand.$noLinks}`); 100 } 101 if (params.$expand.$expandAll !== undefined) { 102 expandParts.push(`$expandAll=${params.$expand.$expandAll}`); 103 } 104 if (params.$expand.$links !== undefined) { 105 expandParts.push(`$links=${params.$expand.$links}`); 106 } 107 108 // Build .(options) without encoding the $ directives 109 // Use ';' between options per OData specification 110 const opts = expandParts.join(';'); 111 pairs.push(`$expand=.(${opts})`); 112 } 113 } 114 115 // Handle $filter parameter 116 if (params.$filter) { 117 pairs.push(`$filter=${encodeURIComponent(params.$filter)}`); 118 } 119 120 // Handle $select parameter 121 if (params.$select) { 122 const sel = Array.isArray(params.$select) 123 ? params.$select.join(',') 124 : params.$select; 125 pairs.push(`$select=${encodeURIComponent(sel)}`); 126 } 127 128 // Handle $top parameter (pagination) 129 if (params.$top !== undefined) { 130 pairs.push(`$top=${encodeURIComponent(String(params.$top))}`); 131 } 132 133 // Handle $skip parameter (pagination) 134 if (params.$skip !== undefined) { 135 pairs.push(`$skip=${encodeURIComponent(String(params.$skip))}`); 136 } 137 138 // Handle 'only' parameter (Redfish-specific) 139 if (params.only) { 140 pairs.push('only='); 141 } 142 143 // Handle 'excerpt' parameter (Redfish-specific) 144 if (params.excerpt !== undefined) { 145 pairs.push(`excerpt=${encodeURIComponent(String(params.excerpt))}`); 146 } 147 148 const qs = pairs.join('&'); 149 return qs ? `${path}?${qs}` : path; 150} 151 152/** 153 * Normalizes Redfish query parameters for cache stability 154 * 155 * Ensures consistent query keys by: 156 * - Sorting array values (like $select) 157 * - Freezing the result to prevent mutations 158 * - Handling undefined values consistently 159 * 160 * @param params - Query parameters to normalize 161 * @returns Normalized and frozen parameters, or undefined if input is undefined 162 */ 163function normalizeRedfishQueryParameters( 164 params?: RedfishQueryParameters, 165): Readonly<RedfishQueryParameters> | undefined { 166 if (!params) return undefined; 167 168 const normalizedSelect = 169 params.$select === undefined 170 ? undefined 171 : Array.isArray(params.$select) 172 ? [...params.$select].sort() 173 : params.$select; 174 175 const normalizedExpand = 176 params.$expand === undefined 177 ? undefined 178 : typeof params.$expand === 'string' 179 ? params.$expand 180 : { 181 $levels: params.$expand.$levels, 182 $noLinks: params.$expand.$noLinks, 183 $expandAll: params.$expand.$expandAll, 184 $links: params.$expand.$links, 185 }; 186 187 return Object.freeze({ 188 $expand: normalizedExpand, 189 $filter: params.$filter, 190 $select: normalizedSelect, 191 $top: params.$top, 192 $skip: params.$skip, 193 only: params.only, 194 excerpt: params.excerpt, 195 }); 196} 197 198/** 199 * Fetches a Redfish collection with optional OData query parameters 200 * Gracefully falls back if BMC doesn't support OData features 201 * 202 * @param path - Collection path (e.g., '/redfish/v1/Chassis') 203 * @param options - Fetch options 204 * @param supportsExpand - Whether BMC supports $expand 205 * @returns Promise with collection data 206 */ 207async function fetchCollection<T>( 208 path: string, 209 options: FetchCollectionOptions, 210 supportsExpand: boolean, 211): Promise<T[]> { 212 const { expand, expandLevels = 1, select, filter } = options; 213 214 // Build query parameters using the reusable buildQuery function 215 const queryParams: RedfishQueryParameters = {}; 216 217 if (expand && supportsExpand) { 218 queryParams.$expand = { $levels: expandLevels }; 219 } 220 221 if (select && select.length > 0) { 222 queryParams.$select = select; 223 } 224 225 if (filter) { 226 queryParams.$filter = filter; 227 } 228 229 const url = buildQuery(path, queryParams); 230 231 try { 232 const { data } = await api.get<RedfishCollection<T>>(url); 233 234 if (expand && supportsExpand && data.Members) { 235 return data.Members; 236 } 237 238 if (data.Members && Array.isArray(data.Members)) { 239 const memberPromises = data.Members.map((member: CollectionMember) => 240 api 241 .get<T>(member['@odata.id']) 242 .then((res: { data: T }) => res.data) 243 .catch((error: Object) => { 244 console.error( 245 `Error fetching member ${member['@odata.id']}:`, 246 error, 247 ); 248 return null; 249 }), 250 ); 251 252 const members = await Promise.all(memberPromises); 253 return members.filter((m: T | null): m is T => m !== null); 254 } 255 256 return []; 257 } catch (error) { 258 // If OData query failed, try without parameters 259 const hasQueryParams = url !== path; 260 if (hasQueryParams) { 261 console.warn( 262 `OData query failed for ${path}, falling back to basic fetch`, 263 ); 264 try { 265 const { data } = 266 await api.get<RedfishCollection<CollectionMember>>(path); 267 268 if (data.Members && Array.isArray(data.Members)) { 269 const memberPromises = data.Members.map((member: CollectionMember) => 270 api 271 .get<T>(member['@odata.id']) 272 .then((res: { data: T }) => res.data) 273 .catch((err: Object) => { 274 console.error( 275 `Error fetching member ${member['@odata.id']}:`, 276 err, 277 ); 278 return null; 279 }), 280 ); 281 282 const members = await Promise.all(memberPromises); 283 return members.filter((m: T | null): m is T => m !== null); 284 } 285 } catch (fallbackError) { 286 console.error(`Failed to fetch collection ${path}:`, fallbackError); 287 throw fallbackError; 288 } 289 } 290 291 console.error(`Failed to fetch collection ${path}:`, error); 292 throw error; 293 } 294} 295 296/** 297 * TanStack Query hook for fetching a Redfish collection 298 * 299 * @param path - Collection path 300 * @param options - Fetch options 301 * @returns TanStack Query result 302 */ 303export function useRedfishCollection<T>( 304 path: string, 305 options: FetchCollectionOptions = {}, 306) { 307 // Get ServiceRoot to check OData support 308 const { data: serviceRoot } = useRedfishRoot(); 309 310 // Compute whether expand is supported 311 const canExpand = computed(() => supportsExpandQuery(serviceRoot.value)); 312 313 // Build query parameters for normalization 314 const queryParams: RedfishQueryParameters = {}; 315 316 if (options.expand) { 317 queryParams.$expand = { $levels: options.expandLevels || 1 }; 318 } 319 320 if (options.select && options.select.length > 0) { 321 queryParams.$select = options.select; 322 } 323 324 if (options.filter) { 325 queryParams.$filter = options.filter; 326 } 327 328 // Normalize query parameters for stable cache keys 329 const normalizedParams = normalizeRedfishQueryParameters(queryParams); 330 331 return useQuery({ 332 queryKey: ['redfish', 'collection', path, normalizedParams], 333 queryFn: () => fetchCollection<T>(path, options, canExpand.value), 334 enabled: computed(() => !!serviceRoot.value), 335 refetchOnMount: false, // Don't refetch when component remounts 336 refetchOnWindowFocus: false, // Don't refetch when window regains focus 337 refetchOnReconnect: false, 338 retry: 2, 339 retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), 340 }); 341} 342