• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

ecamp / hal-json-vuex / 13321877237

13 Feb 2025 10:43PM UTC coverage: 84.708%. Remained the same
13321877237

push

github

web-flow
Update babel monorepo to v7.26.8

159 of 206 branches covered (77.18%)

Branch coverage included in aggregate %.

262 of 291 relevant lines covered (90.03%)

740.07 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

78.05
/src/index.js
1
import normalize from 'hal-json-normalizer'
2
import urltemplate from 'url-template'
3
import { normalizeEntityUri } from './normalizeUri'
4
import StoreValueCreator from './StoreValueCreator'
5
import StoreValue from './StoreValue'
6
import LoadingStoreValue from './LoadingStoreValue'
7
import storeModule from './storeModule'
8
import QueryablePromise from './QueryablePromise'
9

10
/**
11
 * Error class for returning server exceptions (attaches response object to error)
12
 * @param response        Axios response object
13
 * @param ...params       Any other parameters from default Error constructor (message, etc.)
14
 */
15
export class ServerException extends Error {
16
  constructor (response, ...params) {
17
    super(...params)
36✔
18

19
    if (!this.message) {
36!
20
      this.message = 'Server error ' + response.status + ' (' + response.statusText + ')'
×
21
    }
22
    this.name = 'ServerException'
36✔
23
    this.response = response
36✔
24
  }
25
}
26

27
/**
28
 * Defines the API store methods available in all Vue components. The methods can be called as follows:
29
 *
30
 * // In a computed or method or lifecycle hook
31
 * let someEntity = this.api.get('/some/endpoint')
32
 * this.api.reload(someEntity)
33
 *
34
 * // In the <template> part of a Vue component
35
 * <li v-for="book in api.get('/all/my/books').items" :key="book._meta.self">...</li>
36
 */
37
function HalJsonVuex (store, axios, options) {
38
  const defaultOptions = {
333✔
39
    apiName: 'api',
40
    avoidNPlusOneRequests: true,
41
    forceRequestedSelfLink: false,
42
    nuxtInject: null
43
  }
44
  const opts = { ...defaultOptions, ...options, apiRoot: axios.defaults.baseURL }
333✔
45

46
  store.registerModule(opts.apiName, { state: {}, ...storeModule })
333✔
47

48
  const storeValueCreator = new StoreValueCreator({ get, reload, post, patch, del, isUnknown }, opts)
333✔
49

50
  if (opts.nuxtInject !== null) axios = adaptNuxtAxios(axios)
333!
51

52
  /**
53
   * Since Nuxt.js uses $get, $post etc., we need to use an adapter in the case of a Nuxt.js app...
54
   * @param $axios
55
   */
56
  function adaptNuxtAxios ($axios) {
57
    return {
×
58
      get: $axios.$get,
59
      patch: $axios.$patch,
60
      post: $axios.$post,
61
      delete: $axios.$delete,
62
      ...$axios
63
    }
64
  }
65

66
  /**
67
   * Sends a POST request to the backend, in order to create a new entity. Note that this does not
68
   * reload any collections that this new entity might be in, the caller has to do that on its own.
69
   * @param uriOrCollection URI (or instance) of a collection in which the entity should be created
70
   * @param data            Payload to be sent in the POST request
71
   * @returns Promise       resolves when the POST request has completed and the entity is available
72
   *                        in the Vuex store.
73
   */
74
  function post (uriOrCollection, data) {
75
    const uri = normalizeEntityUri(uriOrCollection, axios.defaults.baseURL)
12✔
76
    if (uri === null) {
12!
77
      return Promise.reject(new Error(`Could not perform POST, "${uriOrCollection}" is not an entity or URI`))
×
78
    }
79
    return new QueryablePromise(axios.post(axios.defaults.baseURL + uri, preparePostData(data)).then(({ data }) => {
12✔
80
      storeHalJsonData(data)
12✔
81
      return get(data._links.self.href)
12✔
82
    }, (error) => {
83
      throw handleAxiosError(uri, error)
×
84
    }))
85
  }
86

87
  /**
88
   * Reloads an entity from the API.
89
   *
90
   * @param uriOrEntity URI (or instance) of an entity to reload from the API
91
   * @returns Promise   Resolves when the GET request has completed and the updated entity is available
92
   *                    in the Vuex store.
93
   */
94
  function reload (uriOrEntity) {
95
    return get(uriOrEntity, true)._meta.load
117✔
96
  }
97

98
  /**
99
   * Retrieves an entity from the Vuex store, or from the API in case it is not already fetched or a reload
100
   * is forced.
101
   * This function attempts to hide all backend implementation details such as pagination, linked vs.
102
   * embedded relations and loading state and instead provide an easy-to-use and consistent interface for
103
   * developing frontend components.
104
   *
105
   * Basic usage in a Vue component:
106
   * computed: {
107
   *   allCamps () { return this.api.get('/camp').items }
108
   *   oneSpecificCamp () { return this.api.get(`/camp/${this.campId}`) }
109
   *   campUri () { return this.oneSpecificCamp._meta.self }
110
   *   activityTypes () { return this.oneSpecificCamp.activityTypes() }
111
   *   user () { return this.api.get().profile() } // Root endpoint ('/') and navigate through self-discovery API
112
   * },
113
   * created () {
114
   *   this.oneSpecificCamp._meta.load.then(() => {
115
   *     // do something now that the camp is loaded from the API
116
   *   })
117
   * }
118
   *
119
   * @param uriOrEntity URI (or instance) of an entity to load from the store or API
120
   * @param forceReload If true, the entity will be fetched from the API even if it is already in the Vuex store.
121
   *                    Note that the function will still return the old value in this case, but you can
122
   *                    wait for the new value using the ._meta.load promise.
123
   * @returns entity    Entity from the store. Note that when fetching an object for the first time, a reactive
124
   *                    dummy is returned, which will be replaced with the true data through Vue's reactivity
125
   *                    system as soon as the API request finishes.
126
   */
127
  function get (uriOrEntity, forceReload = false) {
1,227✔
128
    const forceReloadingEmbeddedCollection = forceReload && uriOrEntity._meta && uriOrEntity._meta.reload && uriOrEntity._meta.reload.uri
1,344✔
129
    const uri = forceReloadingEmbeddedCollection
1,344✔
130
      ? normalizeEntityUri(uriOrEntity._meta.reload.uri, axios.defaults.baseURL)
131
      : normalizeEntityUri(uriOrEntity, axios.defaults.baseURL)
132
    if (uri === null) {
1,344✔
133
      if (uriOrEntity instanceof LoadingStoreValue) {
18✔
134
        // A LoadingStoreValue is safe to return without breaking the UI.
135
        return uriOrEntity
12✔
136
      }
137
      // We don't know anything about the requested object, something is wrong.
138
      throw new Error(`Could not perform GET, "${uriOrEntity}" is not an entity or URI`)
6✔
139
    }
140

141
    const storeData = load(uri, forceReload)
1,326✔
142
    return forceReloadingEmbeddedCollection
1,326✔
143
      ? storeValueCreator.wrap(storeData)[uriOrEntity._meta.reload.property]()
144
      : storeValueCreator.wrap(storeData)
145
  }
146

147
  function isUnknown (uri) {
148
    return !(uri in store.state[opts.apiName])
1,647✔
149
  }
150

151
  /**
152
   * Loads the entity specified by the URI from the Vuex store, or from the API if necessary. If applicable,
153
   * sets the load promise on the entity in the Vuex store.
154
   * @param uri         URI of the entity to load
155
   * @param forceReload If true, the entity will be fetched from the API even if it is already in the Vuex store.
156
   * @returns entity    the current entity data from the Vuex store. Note: This may be a reactive dummy if the
157
   *                    backend request is still ongoing.
158
   */
159
  function load (uri, forceReload) {
160
    const existsInStore = !isUnknown(uri)
1,326✔
161

162
    const isAlreadyLoading = existsInStore && (store.state[opts.apiName][uri]._meta || {}).loading
1,326!
163
    const isAlreadyReloading = existsInStore && (store.state[opts.apiName][uri]._meta || {}).reloading
1,326!
164
    if (isAlreadyLoading || (forceReload && isAlreadyReloading)) {
1,326✔
165
      // Reuse the loading entity and load promise that is already waiting for a pending API request
166
      return store.state[opts.apiName][uri]
33✔
167
    }
168

169
    if (!existsInStore) {
1,293✔
170
      store.commit('addEmpty', uri)
357✔
171
    } else if (forceReload) {
936✔
172
      store.commit('reloading', uri)
105✔
173
    }
174

175
    let dataFinishedLoading = Promise.resolve(store.state[opts.apiName][uri])
1,293✔
176
    if (!existsInStore) {
1,293✔
177
      dataFinishedLoading = loadFromApi(uri)
357✔
178
    } else if (forceReload) {
936✔
179
      dataFinishedLoading = loadFromApi(uri).catch(error => {
105✔
180
        store.commit('reloadingFailed', uri)
12✔
181
        throw error
12✔
182
      })
183
    } else if (store.state[opts.apiName][uri]._meta.load) {
831!
184
      // reuse the existing promise from the store if possible
185
      dataFinishedLoading = store.state[opts.apiName][uri]._meta.load
831✔
186
    }
187

188
    setLoadPromiseOnStore(uri, dataFinishedLoading)
1,293✔
189

190
    return store.state[opts.apiName][uri]
1,293✔
191
  }
192

193
  /**
194
   * Loads the entity specified by the URI from the API and stores it into the Vuex store. Returns a promise
195
   * that resolves to the raw data stored in the Vuex store (needs to be storeValueCreator.wrapped into a StoreValue before
196
   * being usable in Vue components).
197
   * @param uri       URI of the entity to load from the API
198
   * @returns Promise resolves to the raw data stored in the Vuex store after the API request completes, or
199
   *                  rejects when the API request fails
200
   */
201
  function loadFromApi (uri) {
202
    return new Promise((resolve, reject) => {
462✔
203
      axios.get(axios.defaults.baseURL + uri).then(
462✔
204
        ({ data }) => {
205
          if (opts.forceRequestedSelfLink) {
432!
206
            data._links.self.href = uri
432✔
207
          }
208
          storeHalJsonData(data)
432✔
209
          resolve(store.state[opts.apiName][uri])
432✔
210
        },
211
        (error) => {
212
          reject(handleAxiosError(uri, error))
30✔
213
        }
214
      )
215
    })
216
  }
217

218
  /**
219
   * Loads the URI of a related entity from the store, or the API in case it is not already fetched.
220
   *
221
   * @param uriOrEntity    URI (or instance) of an entity from the API
222
   * @param relation       the name of the relation for which the URI should be retrieved
223
   * @param templateParams in case the relation is a templated link, the template parameters that should be filled in
224
   * @returns Promise      resolves to the URI of the related entity.
225
   */
226
  async function href (uriOrEntity, relation, templateParams = {}) {
6✔
227
    const self = normalizeEntityUri(await get(uriOrEntity)._meta.load, axios.defaults.baseURL)
12✔
228
    const rel = store.state[opts.apiName][self][relation]
12✔
229
    if (!rel || !rel.href) return undefined
12!
230
    if (rel.templated) {
12✔
231
      return urltemplate.parse(rel.href).expand(templateParams)
6✔
232
    }
233
    return axios.defaults.baseURL + rel.href
6✔
234
  }
235

236
  /**
237
   * Sends a PATCH request to the backend, in order to update some fields in an existing entity.
238
   * @param uriOrEntity URI (or instance) of an entity which should be updated
239
   * @param data        Payload (fields to be updated) to be sent in the PATCH request
240
   * @returns Promise   resolves when the PATCH request has completed and the updated entity is available
241
   *                    in the Vuex store.
242
   */
243
  function patch (uriOrEntity, data) {
244
    const uri = normalizeEntityUri(uriOrEntity, axios.defaults.baseURL)
48✔
245
    if (uri === null) {
48!
246
      return Promise.reject(new Error(`Could not perform PATCH, "${uriOrEntity}" is not an entity or URI`))
×
247
    }
248
    const existsInStore = !isUnknown(uri)
48✔
249

250
    if (!existsInStore) {
48✔
251
      store.commit('addEmpty', uri)
30✔
252
    }
253

254
    store.state[opts.apiName][uri]._meta.load = new QueryablePromise(axios.patch(axios.defaults.baseURL + uri, data).then(({ data }) => {
48✔
255
      if (opts.forceRequestedSelfLink) {
18!
256
        data._links.self.href = uri
18✔
257
      }
258
      storeHalJsonData(data)
18✔
259
      return get(uri)
18✔
260
    }, (error) => {
261
      throw handleAxiosError(uri, error)
30✔
262
    }))
263

264
    return store.state[opts.apiName][uri]._meta.load
48✔
265
  }
266

267
  /**
268
   * Removes a single entity from the Vuex store (but does not delete it using the API). Note that if the
269
   * entity is currently referenced and displayed through any other entity, the reactivity system will
270
   * immediately re-fetch the purged entity from the API in order to re-display it.
271
   * @param uriOrEntity URI (or instance) of an entity which should be removed from the Vuex store
272
   */
273
  function purge (uriOrEntity) {
274
    const uri = normalizeEntityUri(uriOrEntity, axios.defaults.baseURL)
72✔
275
    if (uri === null) {
72!
276
      // Can't purge an unknown URI, do nothing
277
      return
×
278
    }
279
    store.commit('purge', uri)
72✔
280
    return uri
72✔
281
  }
282

283
  /**
284
   * Removes all stored entities from the Vuex store (but does not delete them using the API).
285
   */
286
  function purgeAll () {
287
    store.commit('purgeAll')
×
288
  }
289

290
  /**
291
   * Attempts to permanently delete a single entity using a DELETE request to the API.
292
   * This function performs the following operations when given the URI of an entity E:
293
   * 1. Marks E in the Vuex store with the ._meta.deleting flag
294
   * 2. Sends a DELETE request to the API in order to delete E from the backend (in case of failure, the
295
   *    deleted flag is reset and the operation is aborted)
296
   * 3. Finds all entities [...R] in the store that reference E (e.g. find the corresponding camp when
297
   *    deleting an activity) and reloads them from the API
298
   * 4. Purges E from the Vuex store
299
   * @param uriOrEntity URI (or instance) of an entity which should be deleted
300
   * @returns Promise   resolves when the DELETE request has completed and either all related entites have
301
   *                    been reloaded from the API, or the failed deletion has been cleaned up.
302
   */
303
  function del (uriOrEntity) {
304
    const uri = normalizeEntityUri(uriOrEntity, axios.defaults.baseURL)
42✔
305
    if (uri === null) {
42!
306
      // Can't delete an unknown URI, do nothing
307
      return Promise.reject(new Error(`Could not perform DELETE, "${uriOrEntity}" is not an entity or URI`))
×
308
    }
309
    store.commit('deleting', uri)
42✔
310
    return new QueryablePromise(axios.delete(axios.defaults.baseURL + uri).then(
42✔
311
      () => deleted(uri),
42✔
312
      (error) => {
313
        store.commit('deletingFailed', uri)
×
314
        throw handleAxiosError(uri, error)
×
315
      }
316
    ))
317
  }
318

319
  function valueIsArrayWithReferenceTo (value, uri) {
320
    return Array.isArray(value) && value.some(entry => valueIsReferenceTo(entry, uri))
444✔
321
  }
322

323
  function valueIsReferenceTo (value, uri) {
324
    if (value === null) return false
498!
325

326
    const objectKeys = Object.keys(value)
498✔
327
    return objectKeys.length === 1 && objectKeys[0] === 'href' && value.href === uri
498✔
328
  }
329

330
  function findEntitiesReferencing (uri) {
331
    return Object.values(store.state[opts.apiName])
60✔
332
      .filter((entity) => {
333
        return Object.values(entity).some(propertyValue =>
150✔
334
          valueIsReferenceTo(propertyValue, uri) || valueIsArrayWithReferenceTo(propertyValue, uri)
474✔
335
        )
336
      })
337
  }
338

339
  /**
340
   * Cleans up the Vuex store after an entity is found to be deleted (HTTP status 204 or 404) from the backend.
341
   * @param uri       URI of an entity which is not available (anymore) in the backend
342
   * @returns Promise resolves when the cleanup has completed and the Vuex store is up to date again
343
   */
344
  function deleted (uri) {
345
    return Promise.all(findEntitiesReferencing(uri)
60✔
346
      // don't reload entities that are already being deleted, to break circular dependencies
347
      .filter(outdatedEntity => !outdatedEntity._meta.deleting)
54✔
348

349
      // reload entities but ignore any errors (such as 404 errors during reloading)
350
      .map(outdatedEntity => reload(outdatedEntity).catch(() => {}))
48✔
351
    ).then(() => purge(uri))
60✔
352
  }
353

354
  /**
355
   * Normalizes raw data from the backend and stores it into the Vuex store.
356
   * @param data HAL JSON data received from the backend
357
   */
358
  function storeHalJsonData (data) {
359
    const normalizedData = normalize(data, {
462✔
360
      camelizeKeys: false,
361
      metaKey: '_meta',
362
      normalizeUri: (uri) => normalizeEntityUri(uri, axios.defaults.baseURL),
1,464✔
363
      filterReferences: true,
364
      embeddedStandaloneListKey: 'items'
365
    })
366
    store.commit('add', normalizedData)
462✔
367

368
    Object.keys(normalizedData).forEach(uri => {
462✔
369
      setLoadPromiseOnStore(uri)
825✔
370
    })
371
  }
372

373
  /**
374
   * Mutate the store state without telling Vuex about it, so it won't complain and won't make the load promise
375
   * reactive.
376
   * The promise is needed in the store for some special cases when a loading entity is requested a second time with
377
   * this.api.get(...) or this.api.reload(...), or when an embedded collection is reloaded.
378
   * @param uri
379
   * @param promise
380
   */
381
  function setLoadPromiseOnStore (uri, promise = null) {
825✔
382
    store.state[opts.apiName][uri]._meta.load = promise ? new QueryablePromise(promise) : QueryablePromise.resolve(store.state[opts.apiName][uri])
2,118✔
383
  }
384

385
  /**
386
   * Replace store items with {itemnameId: id}
387
   * @param data to be processed
388
   * @param name is the name of the entities
389
   * @returns cleaned data
390
   */
391
  function preparePostData (data, name = null) {
12✔
392
    return Array.isArray(data)
12!
393
      ? data.map(value => {
394
        if (value !== null && typeof value === 'object') {
×
395
          if (value._meta && value._meta.self) {
×
396
            return name ? { [name.replace(/ies/, 'y') + 'Id']: value.id } : { id: value.id }
×
397
          } else {
398
            return preparePostData(value, name)
×
399
          }
400
        } else {
401
          return value
×
402
        }
403
      })
404
      : Object.fromEntries(Object.entries(data).map(([prop, value]) => {
405
        const type = Object.prototype.toString.call(value)
12✔
406
        if (type.includes('Function')) {
12!
407
          value = value()
×
408
        }
409
        if (value !== null && typeof value === 'object') {
12!
410
          if (value._meta && value._meta.self) {
×
411
            return [prop + 'Id', value.id]
×
412
          } else {
413
            return [prop, preparePostData(value, prop)]
×
414
          }
415
        } else {
416
          return [prop, value]
12✔
417
        }
418
      }))
419
  }
420

421
  /**
422
   * Processes error object received from Axios for further usage. Triggers delete chain as side effect.
423
   * @param uri             Requested URI that triggered the error
424
   * @param error           Raw error object received from Axios
425
   * @returns Error         Return new error object with human understandable error message
426
   */
427
  function handleAxiosError (uri, error) {
428
    // Server Error (response received but with error code)
429
    if (error.response) {
60✔
430
      const response = error.response
36✔
431

432
      if (response.status === 404) {
36✔
433
        // 404 Entity not found error
434
        store.commit('deleting', uri)
18✔
435
        deleted(uri).then(() => {}) // no need to wait for delete operation to finish
18✔
436
        return new ServerException(response, `Could not perform operation, "${uri}" has been deleted`)
18✔
437
      } else if (response.status === 403) {
18✔
438
        // 403 Permission error
439
        return new ServerException(response, 'No permission to perform operation')
12✔
440
      } else if (response.headers['content-type'] === 'application/problem+json') {
6!
441
        // API Problem
442
        return new ServerException(response, 'Server-Error ' + response.status + ' (' + response.data.detail + ')')
6✔
443
      } else {
444
        // other unknown server error (not of type application/problem+json)
445
        return new ServerException(response)
×
446
      }
447
    } else {
448
      // another error (most probably connection timeout; no response received)
449
      return new Error('Could not connect to server. Check your internet connection and try again.')
24✔
450
    }
451
  }
452

453
  const halJsonVuex = { post, get, reload, del, patch, purge, purgeAll, href, isUnknown, StoreValue, LoadingStoreValue }
333✔
454

455
  function install (Vue) {
456
    if (this.installed) return
333!
457

458
    if (opts.nuxtInject === null) {
333!
459
      // Normal installation in a Vue app
460
      Object.defineProperties(Vue.prototype, {
333✔
461
        [opts.apiName]: {
462
          get () {
463
            return halJsonVuex
891✔
464
          }
465
        }
466
      })
467
    } else {
468
      // Support for Nuxt-style inject installation
469
      opts.nuxtInject(opts.apiName, halJsonVuex)
×
470
    }
471
  }
472

473
  return { ...halJsonVuex, install }
333✔
474
}
475

476
export default HalJsonVuex
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc