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

ecamp / hal-json-vuex / 6631556418

24 Oct 2023 07:20PM UTC coverage: 90.85% (-0.04%) from 90.89%
6631556418

push

github

web-flow
Merge pull request #287 from usu/feat/nuxt3-vite

Feat/nuxt3 vite

120 of 143 branches covered (0.0%)

Branch coverage included in aggregate %.

13 of 13 new or added lines in 2 files covered. (100.0%)

297 of 316 relevant lines covered (93.99%)

840.15 hits per line

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

88.94
/src/index.ts
1
import normalize from 'hal-json-normalizer'
9✔
2
import urltemplate from 'url-template'
9✔
3
import normalizeEntityUri from './normalizeEntityUri'
9✔
4
import ResourceCreator from './ResourceCreator'
9✔
5
import Resource from './Resource'
9✔
6
import LoadingResource from './LoadingResource'
9✔
7
import storeModule, { State } from './storeModule'
9✔
8
import ServerException from './ServerException'
9✔
9
import { ExternalConfig } from './interfaces/Config'
10
import { Store } from 'vuex/types'
11
import { AxiosInstance, AxiosError } from 'axios'
12
import ResourceInterface from './interfaces/ResourceInterface'
13
import StoreData, { Link, SerializablePromise } from './interfaces/StoreData'
14
import ApiActions from './interfaces/ApiActions'
15
import { isVirtualResource } from './halHelpers'
9✔
16

17
/**
18
 * Defines the API store methods available in all Vue components. The methods can be called as follows:
19
 *
20
 * // In a computed or method or lifecycle hook
21
 * let book = this.api.get('/books/1')
22
 * this.api.reload(book)
23
 *
24
 * // In the <template> part of a Vue component
25
 * <li v-for="book in api.get('/books').items" :key="book._meta.self">...</li>
26
 */
27

28
// eslint-disable-next-line @typescript-eslint/no-explicit-any
29
function HalJsonVuex (store: Store<Record<string, State>>, axios: AxiosInstance, options: ExternalConfig): any {
30
  const defaultOptions = {
525✔
31
    apiName: 'api',
32
    avoidNPlusOneRequests: true,
33
    forceRequestedSelfLink: false
34
  }
35
  const opts = { ...defaultOptions, ...options, apiRoot: axios.defaults.baseURL }
525✔
36

37
  store.registerModule(opts.apiName, { state: {}, ...storeModule })
525✔
38

39
  const resourceCreator = new ResourceCreator({ get, reload, post, patch, del, href, isUnknown }, opts)
525✔
40

41
  /**
42
   * Sends a POST request to the API, in order to create a new entity. Note that this does not
43
   * reload any collections that this new entity might be in, the caller has to do that on their own.
44
   * @param uriOrCollection URI (or instance) of a collection in which the entity should be created
45
   * @param data            Payload to be sent in the POST request
46
   * @returns Promise       resolves when the POST request has completed and the entity is available
47
   *                        in the Vuex store.
48
   */
49
  function post (uriOrCollection: string | ResourceInterface, data: unknown): Promise<ResourceInterface | null> {
50
    const uri = normalizeEntityUri(uriOrCollection, axios.defaults.baseURL)
42✔
51
    if (uri === null) {
42!
52
      return Promise.reject(new Error(`Could not perform POST, "${uriOrCollection}" is not an entity or URI`))
×
53
    }
54

55
    if (!isUnknown(uri)) {
42✔
56
      const entity = get(uri)
12✔
57
      if (isVirtualResource(entity)) {
12✔
58
        return Promise.reject(new Error('post is not implemented for virtual resources'))
3✔
59
      }
60
    }
61

62
    return axios.post(uri || '/', data).then(({ data, status }) => {
39!
63
      if (status === 204) {
39✔
64
        return null
3✔
65
      }
66
      storeHalJsonData(data)
36✔
67
      return get(data._links.self.href)
36✔
68
    }, (error) => {
69
      throw handleAxiosError('post to', uri, error)
×
70
    })
71
  }
72

73
  /**
74
   * Retrieves an entity from the Vuex store, or from the API in case it is not already fetched or a reload
75
   * is forced.
76
   * This function attempts to hide all API implementation details such as pagination, linked vs.
77
   * embedded relations and loading state and instead provide an easy-to-use and consistent interface for
78
   * developing frontend components.
79
   *
80
   * Basic usage in a Vue component:
81
   * computed: {
82
   *   allBooks () { return this.api.get('/books').items }
83
   *   oneSpecificBook () { return this.api.get(`/books/${this.bookId}`) }
84
   *   bookUri () { return this.oneSpecificBook._meta.self }
85
   *   chapters () { return this.oneSpecificBook.chapters() }
86
   *   user () { return this.api.get().profile() } // Root endpoint ('/') and navigate through self-discovery API
87
   * },
88
   * created () {
89
   *   this.oneSpecificBook._meta.load.then(() => {
90
   *     // do something now that the book is loaded from the API
91
   *   })
92
   * }
93
   *
94
   * @param uriOrEntity URI (or instance) of an entity to load from the store or API. If omitted, the root resource of the API is returned.
95
   * @returns entity    Entity from the store. Note that when fetching an object for the first time, a reactive
96
   *                    dummy is returned, which will be replaced with the true data through Vue's reactivity
97
   *                    system as soon as the API request finishes.
98
   */
99
  function get (uriOrEntity: string | ResourceInterface = ''): ResourceInterface {
126✔
100
    const uri = normalizeEntityUri(uriOrEntity, axios.defaults.baseURL)
2,169✔
101

102
    if (uri === null) {
2,169✔
103
      if (uriOrEntity instanceof LoadingResource) {
18✔
104
        // A LoadingResource is safe to return without breaking the UI.
105
        return uriOrEntity
12✔
106
      }
107
      // We don't know anything about the requested object, something is wrong.
108
      throw new Error(`Could not perform GET, "${uriOrEntity}" is not an entity or URI`)
6✔
109
    }
110

111
    setLoadPromiseOnStore(uri, load(uri, false))
2,151✔
112
    return resourceCreator.wrap(store.state[opts.apiName][uri])
2,151✔
113
  }
114

115
  /**
116
   * Reloads an entity from the API and returns a Promise that resolves when the reload has finished.
117
   * This function contains protection against duplicate network requests, so while a reload is running,
118
   * no second reload for the same URI will be triggered.
119
   * Reloading does not set the ._meta.loading boolean flag.
120
   *
121
   * @param uriOrEntity URI (or instance) of an entity to reload from the API
122
   * @returns Promise   Resolves when the GET request has completed and the updated entity is available
123
   *                    in the Vuex store.
124
   */
125
  async function reload (uriOrEntity: string | ResourceInterface): Promise<ResourceInterface> {
126
    let resource: ResourceInterface
127

128
    if (typeof uriOrEntity === 'string') {
204✔
129
      resource = get(uriOrEntity)
141✔
130
    } else {
131
      resource = uriOrEntity
63✔
132
    }
133

134
    if (isVirtualResource(resource)) {
204✔
135
      // For embedded collections which had to reload the parent entity, unwrap the embedded collection after loading has finished
136
      const { owningResource, owningRelation } = resource._storeData._meta
48✔
137
      return reload(owningResource).then(owner => owner[owningRelation]())
48✔
138
    }
139

140
    const uri = normalizeEntityUri(resource, axios.defaults.baseURL)
156✔
141

142
    if (uri === null) {
156!
143
      // We don't know anything about the requested object, something is wrong.
144
      throw new Error(`Could not perform reload, "${uriOrEntity}" is not an entity or URI`)
×
145
    }
146

147
    const loadPromise = load(uri, true)
156✔
148
    // Catch all errors for the Promise that is saved to the store, to avoid unhandled promise rejections.
149
    // The errors are still available to catch on the promise returned by reload.
150
    setLoadPromiseOnStore(uri, loadPromise.catch(() => {
156✔
151
      return store.state[opts.apiName][uri]
36✔
152
    }))
153

154
    return loadPromise.then(storeData => resourceCreator.wrap(storeData))
156✔
155
  }
156

157
  /**
158
   * Returns true if uri doesn't exist in store (never loaded before)
159
   * @param uri
160
   */
161
  function isUnknown (uri: string): boolean {
162
    return !(uri in store.state[opts.apiName])
2,859✔
163
  }
164

165
  /**
166
   * Loads the entity specified by the URI from the Vuex store, or from the API if necessary. If applicable,
167
   * sets the load promise on the entity in the Vuex store.
168
   * @param uri         URI of the entity to load
169
   * @param forceReload If true, the entity will be fetched from the API even if it is already in the Vuex store.
170
   * @returns entity    the current entity data from the Vuex store. Note: This may be a reactive dummy if the
171
   *                    API request is still ongoing.
172
   */
173
  function load (uri: string, forceReload: boolean): Promise<StoreData> {
174
    const existsInStore = !isUnknown(uri)
2,307✔
175

176
    const isAlreadyLoading = existsInStore && (store.state[opts.apiName][uri]._meta || {}).loading
2,307!
177
    const isAlreadyReloading = existsInStore && (store.state[opts.apiName][uri]._meta || {}).reloading
2,307!
178
    if (isAlreadyLoading || (forceReload && isAlreadyReloading)) {
2,307✔
179
      // Reuse the loading entity and load promise that is already waiting for a pending API request
180
      return store.state[opts.apiName][uri]._meta.load
144✔
181
    }
182

183
    if (!existsInStore) {
2,163✔
184
      store.commit('addEmpty', uri)
567✔
185
    } else if (forceReload) {
1,596✔
186
      store.commit('reloading', uri)
144✔
187
    }
188

189
    if (!existsInStore) {
2,163✔
190
      return loadFromApi(uri, 'fetch')
567✔
191
    } else if (forceReload) {
1,596✔
192
      return loadFromApi(uri, 'reload').catch(error => {
144✔
193
        store.commit('reloadingFailed', uri)
36✔
194
        throw error
36✔
195
      })
196
    }
197

198
    // Reuse the existing promise from the store if possible
199
    return store.state[opts.apiName][uri]._meta.load || Promise.resolve(store.state[opts.apiName][uri])
1,452!
200
  }
201

202
  /**
203
   * Loads the entity specified by the URI from the API and stores it into the Vuex store. Returns a promise
204
   * that resolves to the raw data stored in the Vuex store (needs to be resourceCreator.wrapped into a Resource before
205
   * being usable in Vue components).
206
   * @param uri       URI of the entity to load from the API
207
   * @param operation description of the operation triggering this load, e.g. fetch or reload, for error reporting
208
   * @returns Promise resolves to the raw data stored in the Vuex store after the API request completes, or
209
   *                  rejects when the API request fails
210
   */
211
  function loadFromApi (uri: string, operation: string): Promise<StoreData> {
212
    return axios.get(uri || '/').then(({ data }) => {
711✔
213
      if (opts.forceRequestedSelfLink) {
651✔
214
        data._links.self.href = uri
651✔
215
      }
216
      storeHalJsonData(data)
651✔
217
      return store.state[opts.apiName][uri]
651✔
218
    }, error => {
219
      throw handleAxiosError(operation, uri, error)
60✔
220
    })
221
  }
222

223
  /**
224
   * Loads the URI of a related entity from the store, or the API in case it is not already fetched.
225
   *
226
   * @param uriOrEntity    URI (or instance) of an entity from the API
227
   * @param relation       the name of the relation for which the URI should be retrieved
228
   * @param templateParams in case the relation is a templated link, the template parameters that should be filled in
229
   * @returns Promise      resolves to the URI of the related entity.
230
   */
231
  async function href (uriOrEntity: string | ResourceInterface, relation: string, templateParams:Record<string, string | number | boolean> = {}): Promise<string | undefined> {
102✔
232
    const selfUri = normalizeEntityUri(await get(uriOrEntity)._meta.load, axios.defaults.baseURL)
117✔
233
    const rel = selfUri != null ? store.state[opts.apiName][selfUri][relation] : null
117!
234
    if (!rel || !rel.href) return undefined
117!
235
    if (rel.templated) {
117✔
236
      return urltemplate.parse(rel.href).expand(templateParams)
12✔
237
    }
238
    return rel.href
105✔
239
  }
240

241
  /**
242
   * Sends a PATCH request to the API, in order to update some fields in an existing entity.
243
   * @param uriOrEntity URI (or instance) of an entity which should be updated
244
   * @param data        Payload (fields to be updated) to be sent in the PATCH request
245
   * @returns Promise   resolves when the PATCH request has completed and the updated entity is available
246
   *                    in the Vuex store.
247
   */
248
  function patch (uriOrEntity: string | ResourceInterface, data: unknown) : Promise<ResourceInterface> {
249
    const uri = normalizeEntityUri(uriOrEntity, axios.defaults.baseURL)
75✔
250
    if (uri === null) {
75!
251
      return Promise.reject(new Error(`Could not perform PATCH, "${uriOrEntity}" is not an entity or URI`))
×
252
    }
253
    const existsInStore = !isUnknown(uri)
75✔
254

255
    if (existsInStore) {
75✔
256
      const entity = get(uri)
21✔
257
      if (isVirtualResource(entity)) {
21✔
258
        return Promise.reject(new Error('patch is not implemented for virtual resources'))
3✔
259
      }
260
    }
261

262
    if (!existsInStore) {
72✔
263
      store.commit('addEmpty', uri)
54✔
264
    }
265

266
    const returnedResource = axios.patch(uri || '/', data).then(({ data }) => {
72!
267
      if (opts.forceRequestedSelfLink) {
42✔
268
        data._links.self.href = uri
42✔
269
      }
270
      storeHalJsonData(data)
42✔
271
      return get(uri)
42✔
272
    }, (error) => {
273
      throw handleAxiosError('patch', uri, error)
30✔
274
    })
275

276
    return returnedResource
72✔
277
  }
278

279
  /**
280
   * Removes a single entity from the Vuex store (but does not delete it using the API). Note that if the
281
   * entity is currently referenced and displayed through any other entity, the reactivity system will
282
   * immediately re-fetch the purged entity from the API in order to re-display it.
283
   * @param uriOrEntity URI (or instance) of an entity which should be removed from the Vuex store
284
   */
285
  function purge (uriOrEntity: string | ResourceInterface): void {
286
    const uri = normalizeEntityUri(uriOrEntity, axios.defaults.baseURL)
132✔
287
    if (uri === null) {
132!
288
      // Can't purge an unknown URI, do nothing
289
      return
×
290
    }
291
    store.commit('purge', uri)
132✔
292
  }
293

294
  /**
295
   * Removes all stored entities from the Vuex store (but does not delete them using the API).
296
   */
297
  function purgeAll (): void {
298
    store.commit('purgeAll')
×
299
  }
300

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

321
    if (!isUnknown(uri)) {
75✔
322
      const entity = get(uri)
51✔
323
      if (isVirtualResource(entity)) {
51✔
324
        return Promise.reject(new Error('del is not implemented for virtual resources'))
3✔
325
      }
326
    }
327

328
    store.commit('deleting', uri)
72✔
329
    return axios.delete(uri || '/').then(
72!
330
      () => deleted(uri),
72✔
331
      (error) => {
332
        store.commit('deletingFailed', uri)
×
333
        throw handleAxiosError('delete', uri, error)
×
334
      }
335
    )
336
  }
337

338
  function valueIsArrayWithReferenceTo (value: unknown, uri: string) {
339
    return Array.isArray(value) && value.some(entry => valueIsReferenceTo(entry, uri))
864✔
340
  }
341

342
  function valueIsReferenceTo (value: unknown, uri: string): boolean {
343
    if (value === null) return false
993!
344
    if (typeof value !== 'object') return false
993✔
345

346
    const objectKeys = Object.keys(value as Record<string, unknown>)
657✔
347
    return objectKeys.length === 1 && objectKeys[0] === 'href' && (value as Link).href === uri
657✔
348
  }
349

350
  function findEntitiesReferencing (uri: string) : Array<StoreData> {
351
    return Object.values(store.state[opts.apiName])
120✔
352
      .filter((entity) => {
353
        return Object.values(entity).some(propertyValue =>
336✔
354
          valueIsReferenceTo(propertyValue, uri) || valueIsArrayWithReferenceTo(propertyValue, uri)
939✔
355
        )
356
      })
357
  }
358

359
  /**
360
   * Cleans up the Vuex store after an entity is found to be deleted (HTTP status 204 or 404) from the API.
361
   * @param uri       URI of an entity which is not available (anymore) in the API
362
   * @returns Promise resolves when the cleanup has completed and the Vuex store is up to date again
363
   */
364
  function deleted (uri: string): Promise<void> {
365
    return Promise.all(findEntitiesReferencing(uri)
120✔
366
      // don't reload entities that are already being deleted, to break circular dependencies
367
      .filter(outdatedEntity => !outdatedEntity._meta.deleting)
105✔
368

369
      // reload outdated entities...
370
      .map(outdatedEntity => reload(outdatedEntity._meta.self).catch(() => {
81✔
371
        // ...but ignore any errors (such as 404 errors during reloading)
372
        // handleAxiosError will take care of recursively deleting cascade-deleted entities
373
      }))
374
    ).then(() => purge(uri))
120✔
375
  }
376

377
  /**
378
   * Normalizes raw data from the API and stores it into the Vuex store.
379
   * @param data HAL JSON data received from the API
380
   */
381
  function storeHalJsonData (data: Record<string, unknown>): void {
382
    const normalizedData = normalize(data, {
729✔
383
      camelizeKeys: false,
384
      metaKey: '_meta',
385
      normalizeUri: (uri: string) => normalizeEntityUri(uri, axios.defaults.baseURL),
1,953✔
386
      filterReferences: true,
387
      embeddedStandaloneListKey: 'items',
388
      virtualSelfLinks: true
389
    })
390
    store.commit('add', normalizedData)
729✔
391

392
    // sets dummy promise which immediately resolves to store data
393
    Object.keys(normalizedData).forEach(uri => {
729✔
394
      setLoadPromiseOnStore(uri)
1,254✔
395
    })
396
  }
397

398
  /**
399
   * Mutate the store state without telling Vuex about it, so it won't complain and won't make the load promise
400
   * reactive.
401
   * The promise is needed in the store for some special cases when a loading entity is requested a second time with
402
   * this.api.get(...) or this.api.reload(...), or when an embedded collection is reloaded.
403
   * @param uri
404
   * @param loadStoreData
405
   */
406
  function setLoadPromiseOnStore (uri: string, loadStoreData: Promise<StoreData> | null = null) {
1,254✔
407
    const promise: SerializablePromise<StoreData> = loadStoreData || Promise.resolve(store.state[opts.apiName][uri])
3,561✔
408
    promise.toJSON = () => '{}' // avoid warning in Nuxt when serializing the complete Vuex store ("Cannot stringify arbitrary non-POJOs Promise")
3,561✔
409
    store.state[opts.apiName][uri]._meta.load = promise
3,561✔
410
  }
411

412
  /**
413
   * Processes error object received from Axios for further usage. Triggers delete chain as side effect.
414
   * @param operation       Describes the action that was ongoing while the error happened, e.g. get or reload
415
   * @param uri             Requested URI that triggered the error
416
   * @param error           Raw error object received from Axios
417
   * @returns Error         Return new error object with human understandable error message
418
   */
419
  function handleAxiosError (operation: string, uri: string, error: AxiosError): Error {
420
    // Server Error (response received but with error code)
421
    if (error.response) {
90✔
422
      const response = error.response
66✔
423

424
      if (response.status === 404) {
66✔
425
        // 404 Entity not found error
426
        store.commit('deleting', uri)
48✔
427
        deleted(uri) // no need to wait for delete operation to finish
48✔
428
        return new ServerException(response, `Could not ${operation} "${uri}"`, error)
48✔
429
      } else if (response.status === 403) {
18✔
430
        // 403 Permission error
431
        return new ServerException(response, `No permission to ${operation} "${uri}"`, error)
12✔
432
      } else {
433
        // other unknown server error
434
        return new ServerException(response, `Error trying to ${operation} "${uri}"`, error)
6✔
435
      }
436
    } else {
437
      // another error
438
      error.message = `Error trying to ${operation} "${uri}": ${error.message}`
24✔
439
      return error
24✔
440
    }
441
  }
442

443
  const apiActions: ApiActions = { post, get, reload, del, patch, href, isUnknown }
525✔
444
  const halJsonVuex = { ...apiActions, purge, purgeAll, href, Resource, LoadingResource }
525✔
445

446
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
447
  function install (app: any) {
448
    if (app.version && app.version.charAt(0) === '3') {
429!
449
      Object.defineProperties(app.config.globalProperties, {
429✔
450
        [opts.apiName]: {
451
          get () {
452
            return halJsonVuex
1,116✔
453
          }
454
        }
455
      })
456
    } else {
457
      throw new Error('Vue2 detected: this version of hal-json-vuex is not compatible with Vue2')
×
458
    }
459
  }
460

461
  return { ...halJsonVuex, install }
525✔
462
}
463

464
export default HalJsonVuex
9✔
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

© 2025 Coveralls, Inc