xref: /openbmc/webui-vue/src/api/composables/useRedfishCollection.ts (revision d3b05033fe82aed6c53f4f2b52c23d3bd285d423)
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