xref: /openbmc/webui-vue/src/store/api.js (revision 60d5c9ebefbd0d6d96343d1fac360577407f6889)
1import Axios from 'axios';
2import router from '../router';
3import { setupCache, buildWebStorage } from 'axios-cache-interceptor';
4import { toRaw } from 'vue';
5
6//Do not change store import.
7//Exact match alias set to support
8//dotenv customizations.
9import store from '.';
10
11Axios.defaults.headers.common['Accept'] = 'application/json';
12Axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
13
14const axiosInstance = Axios.create({
15  withCredentials: true,
16});
17
18const api = setupCache(axiosInstance, {
19  debug: process.env.NODE_ENV === 'development' ? console.log : undefined,
20  methods: ['get'],
21  interpretHeader: false,
22  etag: true,
23  modifiedSince: false,
24  staleIfError: false,
25  ttl: 0,
26  storage: buildWebStorage(localStorage, 'webui-vue-cache:'),
27});
28
29/**
30 * Strip Vue 3 reactivity properties and Event objects from request payloads.
31 *
32 * Vue 3 Migration Issue:
33 * In Bootstrap-Vue-Next, @change events pass Event objects instead of values.
34 * When reactive form data accidentally includes Event properties (isTrusted,
35 * _vts, etc.), the API receives malformed payloads like:
36 *   {"LocationIndicatorActive": {"isTrusted": true, "_vts": 1765562875420}}
37 * instead of:
38 *   {"LocationIndicatorActive": true}
39 *
40 * This function provides a safety net by:
41 * 1. Unwrapping Vue Proxy objects using toRaw()
42 * 2. Detecting Event-like objects and logging warnings
43 * 3. Using JSON round-trip to strip non-serializable properties
44 */
45function stripVueReactivity(obj) {
46  if (obj === null || obj === undefined) return obj;
47
48  // Get raw value (unwrap Vue Proxy)
49  const raw = toRaw(obj);
50
51  // Return primitives as-is
52  if (typeof raw !== 'object') return raw;
53
54  // Skip binary/upload types - they must be passed through unchanged
55  // These types lose their data when JSON serialized
56  if (
57    raw instanceof File ||
58    raw instanceof Blob ||
59    raw instanceof FormData ||
60    ArrayBuffer.isView(raw) ||
61    raw instanceof ArrayBuffer
62  ) {
63    return raw;
64  }
65
66  // Detect Event objects that were accidentally passed as values
67  // This happens when @change handlers pass the event instead of the value
68  if (raw instanceof Event || typeof raw.isTrusted === 'boolean') {
69    console.warn(
70      'API payload contains Event object - check component @change handlers:',
71      raw,
72    );
73  }
74
75  // For plain objects, use JSON round-trip to strip non-serializable properties
76  // This removes Vue internal properties (_vts, __v_isRef, etc.) and Event metadata
77  try {
78    return JSON.parse(JSON.stringify(raw));
79  } catch (e) {
80    // If serialization fails (circular refs, etc.), log and return raw
81    console.warn('Could not serialize API payload:', e);
82    return raw;
83  }
84}
85
86// Add request interceptor to strip Vue reactivity from payloads
87api.interceptors.request.use(
88  (config) => {
89    if (config.data) {
90      config.data = stripVueReactivity(config.data);
91    }
92    return config;
93  },
94  (error) => Promise.reject(error),
95);
96
97api.interceptors.response.use(
98  (response) => {
99    return response;
100  },
101  (error) => {
102    const response = error?.response;
103    const status = response?.status;
104
105    if (!status) return Promise.reject(error);
106
107    // Auth handling
108    if (status == 401) {
109      if (response.config.url != '/login') {
110        store.commit('authentication/logout');
111        router.push('/login');
112      }
113    }
114
115    if (status == 403) {
116      if (isPasswordExpired(response.data)) {
117        router.push('/change-password');
118      } else {
119        store.commit('global/setUnauthorized');
120      }
121    }
122
123    return Promise.reject(error);
124  },
125);
126
127export default {
128  get(path, config) {
129    return api.get(path, config);
130  },
131  delete(path, config) {
132    return api.delete(path, config);
133  },
134  post(path, payload, config) {
135    return api.post(path, payload, config);
136  },
137  patch(path, payload, config) {
138    return api.patch(path, payload, config);
139  },
140  put(path, payload, config) {
141    return api.put(path, payload, config);
142  },
143  all(promises) {
144    return Axios.all(promises);
145  },
146  spread(callback) {
147    return Axios.spread(callback);
148  },
149  set_auth_token(token) {
150    axiosInstance.defaults.headers.common['X-Auth-Token'] = token;
151  },
152};
153
154export const getResponseCount = (responses) => {
155  let successCount = 0;
156  let errorCount = 0;
157
158  responses.forEach((response) => {
159    if (response instanceof Error) errorCount++;
160    else successCount++;
161  });
162
163  return {
164    successCount,
165    errorCount,
166  };
167};
168
169export const isPasswordExpired = (data) => {
170  return !!findMessageId(data, 'PasswordChangeRequired');
171};
172
173/**
174 * Returns the first ExtendedInfo.Message to start with the
175 * Registry Name (Default: "Base") and end with the given key
176 * Ignore versions (.<X>.<Y>) --or-- (.<X>.<Y>.<Z>.),
177 *   but adhere to Registry namespace
178 * @param {object} data - AxiosResponse.data
179 * @param { {MessageKey: string}} key - key into the message registry
180 * @param { {MessageRegistryPrefix: string}} [registry=Base] - the name of the
181 *        message registry, undefined param defaults to "Base"
182 * @returns {ExtendedInfo.Message} ExtendedInfo.Message | undefined
183 */
184export const findMessageId = (data, key, registry = 'Base') => {
185  let extInfoMsgs = data?.['@Message.ExtendedInfo'];
186
187  return (
188    extInfoMsgs &&
189    extInfoMsgs.find((i) => {
190      const words = i.MessageId.split('.');
191      return words[words.length - 1] === key && words[0] === registry;
192    })
193  );
194};
195