Skip to content

Commit

Permalink
Merge pull request #378 from carlobeltrame/templated-relations
Browse files Browse the repository at this point in the history
Add support for templated relations in frontend
  • Loading branch information
usu committed Mar 14, 2020
2 parents bf9a4df + 25d1546 commit 137f2e2
Show file tree
Hide file tree
Showing 6 changed files with 144 additions and 10 deletions.
5 changes: 5 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"inter-ui": "^3.12.1",
"lodash": "^4.17.15",
"slugify": "^1.3.6",
"url-template": "^2.0.8",
"vue": "^2.6.11",
"vue-axios": "^2.1.5",
"vue-router": "^3.1.6",
Expand Down
18 changes: 12 additions & 6 deletions frontend/src/store/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Vuex from 'vuex'
import axios from 'axios'
import VueAxios from 'vue-axios'
import normalize from 'hal-json-normalizer'
import urltemplate from 'url-template'
import { normalizeEntityUri } from '@/store/uriUtils'
import storeValueProxy from '@/store/storeValueProxy'

Expand Down Expand Up @@ -217,14 +218,19 @@ function loadFromApi (uri) {
/**
* Loads the URI of a related entity from the store, or the API in case it is not already fetched.
*
* @param uriOrEntity URI (or instance) of an entity from the API
* @param relation the name of the relation for which the URI should be retrieved
* @returns Promise resolves to the URI of the related entity.
* @param uriOrEntity URI (or instance) of an entity from the API
* @param relation the name of the relation for which the URI should be retrieved
* @param templateParams in case the relation is a templated link, the template parameters that should be filled in
* @returns Promise resolves to the URI of the related entity.
*/
export const href = async function (uriOrEntity, relation) {
export const href = async function (uriOrEntity, relation, templateParams = {}) {
const self = normalizeEntityUri(await get(uriOrEntity)._meta.load, API_ROOT)
const href = (store.state.api[self][relation] || {}).href
return href ? API_ROOT + href : href
const rel = store.state.api[self][relation]
if (!rel || !rel.href) return undefined
if (rel.templated) {
return API_ROOT + urltemplate.parse(rel.href).expand(templateParams)
}
return API_ROOT + rel.href
}

/**
Expand Down
24 changes: 20 additions & 4 deletions frontend/src/store/storeValueProxy.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,19 @@
import { get, API_ROOT } from './index'
import urltemplate from 'url-template'
import { API_ROOT, get } from './index'

function isEqualIgnoringOrder (array, other) {
return array.length === other.length && array.every(elem => other.includes(elem))
}

/**
* A templated link in the Vuex store looks like this: { href: '/some/uri{/something}', templated: true }
* @param object to be examined
* @returns boolean true if the object looks like a templated link, false otherwise
*/
function isTemplatedLink (object) {
if (!object) return false
return isEqualIgnoringOrder(Object.keys(object), ['href', 'templated']) && (object.templated === true)
}

/**
* An entity reference in the Vuex store looks like this: { href: '/some/uri' }
Expand All @@ -7,8 +22,7 @@ import { get, API_ROOT } from './index'
*/
function isEntityReference (object) {
if (!object) return false
const objectKeys = Object.keys(object)
return objectKeys.length === 1 && objectKeys[0] === 'href'
return isEqualIgnoringOrder(Object.keys(object), ['href'])
}

function containsLoadingEntityReference (array) {
Expand Down Expand Up @@ -75,7 +89,7 @@ function loadingProxy (entityLoaded, uri = null) {
return loadingArrayProxy(propertyLoaded)
}
// Normal property access: return a function that yields another loadingProxy and renders as empty string
const result = () => loadingProxy(propertyLoaded.then(property => property()._meta.load))
const result = templateParams => loadingProxy(propertyLoaded.then(property => property(templateParams)._meta.load))
result.toString = () => ''
return result
}
Expand Down Expand Up @@ -221,6 +235,8 @@ function createStoreValueProxy (data) {
result[key] = () => embeddedCollectionProxy(value, data._meta.self, key)
} else if (isEntityReference(value)) {
result[key] = () => get(value.href)
} else if (isTemplatedLink(value)) {
result[key] = templateParams => get(urltemplate.parse(value.href).expand(templateParams))
} else {
result[key] = value
}
Expand Down
52 changes: 52 additions & 0 deletions frontend/tests/unit/api-store.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import collectionFirstPage from './resources/collection-firstPage'
import collectionPage1 from './resources/collection-page1'
import circularReference from './resources/circular-reference'
import multipleReferencesToUser from './resources/multiple-references-to-user'
import templatedLink from './resources/templated-link'

async function letNetworkRequestFinish () {
await new Promise(resolve => {
Expand Down Expand Up @@ -841,4 +842,55 @@ describe('API store', () => {
expect(await load).toEqual('http://localhost/users/83')
done()
})

it('gets the href of a templated linked entity without fetching the entity itself', async done => {
// given
axiosMock.onGet('http://localhost/camps/1').replyOnce(200, templatedLink.linkingServerResponse)
axiosMock.onGet('http://localhost/users/83').networkError()

// when
const load = vm.api.href('/camps/1', 'users', { id: 83 })

// then
await letNetworkRequestFinish()
expect(await load).toEqual('http://localhost/camps/1/users/83')
expect(vm.$store.state.api).toMatchObject(templatedLink.storeStateBeforeLinkedLoaded)
done()
})

it('imports templated link to single entity when linking entity is still loading', async done => {
// given
axiosMock.onGet('http://localhost/camps/1').reply(200, templatedLink.linkingServerResponse)
axiosMock.onGet('http://localhost/camps/1/users/83').reply(200, templatedLink.linkedServerResponse)
const loadingCamp = vm.api.get('/camps/1')

// when
const load = loadingCamp.users({ id: 83 })._meta.load

// then
await letNetworkRequestFinish()
expect(vm.$store.state.api).toMatchObject(templatedLink.storeStateAfterLinkedLoaded)
expect(await load).toMatchObject({ id: 83, name: 'Pflock', _meta: { self: 'http://localhost/camps/1/users/83' } })
done()
})

it('imports templated link to single entity when linking entity is already loaded', async done => {
// given
axiosMock.onGet('http://localhost/camps/1').reply(200, templatedLink.linkingServerResponse)
axiosMock.onGet('http://localhost/camps/1/users/83').reply(200, templatedLink.linkedServerResponse)
vm.api.get('/camps/1')
await letNetworkRequestFinish()
const camp = vm.api.get('/camps/1')

// when
const load = camp.users({ id: 83 })._meta.load

// then
expect(vm.$store.state.api).toMatchObject(templatedLink.storeStateBeforeLinkedLoaded)
expect(vm.$store.state.api).not.toMatchObject(templatedLink.storeStateAfterLinkedLoaded)
await letNetworkRequestFinish()
expect(vm.$store.state.api).toMatchObject(templatedLink.storeStateAfterLinkedLoaded)
expect(await load).toMatchObject({ id: 83, name: 'Pflock', _meta: { self: 'http://localhost/camps/1/users/83' } })
done()
})
})
54 changes: 54 additions & 0 deletions frontend/tests/unit/resources/templated-link.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
{
"linkingServerResponse": {
"id": 1,
"_links": {
"self": {
"href": "/camps/1"
},
"users": {
"href": "/camps/1/users{/id}",
"templated": true
}
}
},
"linkedServerResponse": {
"id": 83,
"name": "Pflock",
"_links": {
"self": {
"href": "/camps/1/users/83"
}
}
},
"storeStateBeforeLinkedLoaded": {
"/camps/1": {
"id": 1,
"users": {
"href": "/camps/1/users{/id}",
"templated": true
},
"_meta": {
"self": "/camps/1"
}
}
},
"storeStateAfterLinkedLoaded": {
"/camps/1": {
"id": 1,
"users": {
"href": "/camps/1/users{/id}",
"templated": true
},
"_meta": {
"self": "/camps/1"
}
},
"/camps/1/users/83": {
"id": 83,
"name": "Pflock",
"_meta": {
"self": "/camps/1/users/83"
}
}
}
}

0 comments on commit 137f2e2

Please sign in to comment.