New Research: Supply Chain Attack on Axios Pulls Malicious Dependency from npm.Details
Socket
Book a DemoSign in
Socket

clockvine

Package Overview
Dependencies
Maintainers
1
Versions
15
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

clockvine - npm Package Compare versions

Comparing version
2.0.0-alpha.8
to
2.0.0-alpha.10
+8
.editorconfig
root = true
[*]
charset = utf-8
end_of_file = lf
indent_style = space
indent_size = 2
trim_trailing_whitespace = true

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

/**
* AccessObserver returns Proxy for objects to provide some lightweight access tracking.
*/
export default class AccessObserver {
#callback
#accessedKeys = new Set()
/**
* Constructor
*
* @param {function} callback - called whenever a property of an observed object is accessed.
*/
constructor (callback = () => {}) {
this.#callback = callback
}
/**
* Return all the keys for all properties accessed.
*
* @return {array}
*/
accessedKeys () {
return [...this.#accessedKeys]
}
/**
* Return a Proxy for object obj.
*
* @param {object} obj
* @return {Proxy}
*/
observe (obj) {
const callback = this.#callback
return new Proxy(obj, {
get: (target, key, receiver) => {
if (Reflect.has(target, key)) {
this.#accessedKeys.add(key)
callback(target, key)
}
return Reflect.get(target, key, receiver)
}
})
}
}

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

export default function StackObjects (...objs) {
return new Proxy({}, {
get (_, prop) {
return objs.find(obj => prop in obj)?.[prop]
},
has (_, prop) {
return objs.some(obj => prop in obj)
}
})
}
export default function TrackUsed (obj) {
const usedKeys = new Set()
return new Proxy(obj, {
get (target, key) {
if (key === 'getUsed') {
return () => Object.fromEntries(Object.entries(obj).filter(([k]) => usedKeys.has(k)))
} else if (key === 'getUnused') {
return () => Object.fromEntries(Object.entries(obj).filter(([k]) => !usedKeys.has(k)))
} else {
usedKeys.add(key)
return target[key]
}
}
})
}
import { compile, pathToRegexp } from 'path-to-regexp'
import AccessObserver from './AccessObserver.js'
import StackObjects from './StackObjects.js'
export default class UrlExp {
#pattern
#regexp
#toPath
constructor (pattern, regexp = null, toPath = null) {
this.#pattern = pattern
this.#regexp = regexp ?? pathToRegexp(pattern)
this.#toPath = toPath ?? compile(pattern, { encode: encodeURIComponent })
}
format (args = {}, optionalArgs = {}) {
const observer = new AccessObserver()
const observed = observer.observe(args)
const path = this.#toPath(StackObjects(observed, optionalArgs))
const accessedKeys = observer.accessedKeys()
const params = new URLSearchParams(Object.fromEntries(Object.entries(args).filter(([key]) => !accessedKeys.includes(key))))
return path + (params.toString() ? `?${params}` : '')
}
}
export default function useXsrfTokens (
jsonApi,
{
tokenHeader = 'X-XSRF-TOKEN',
cookieRegex = /XSRF-TOKEN=([^; ]*)/,
additionalHeaders = {
Accept: 'application/json'
}
} = {}
) {
const fetch = jsonApi.config.fetch
jsonApi.config.fetch = (resource, options = {}) => {
const headers = new Headers(options.headers || {})
if (cookieRegex.test(document.cookie)) {
const token = decodeURIComponent(document.cookie.match(cookieRegex)[1])
headers.set(tokenHeader, token)
}
for (const header in additionalHeaders) {
if (!headers.has(header)) {
headers.set(header, additionalHeaders[header])
}
}
options.headers = headers
return fetch(resource, options)
}
}
import { expect, test, vi } from 'vitest'
import AccessObserver from '../src/AccessObserver.js'
test('observes fires when attributes are acccessed', () => {
const obj = { name: 'kevin' }
const callback = vi.fn()
const observer = new AccessObserver(callback)
const observed = observer.observe(obj)
console.log(`Accessing ${observed.name}`)
expect(callback).toHaveBeenCalledTimes(1)
})
test('tracks key access', () => {
const obj = { firstname: 'kevin', lastname: 'hamer' }
const observer = new AccessObserver()
const observed = observer.observe(obj)
console.log(`Accessing ${observed.firstname} and ${observed.age}.`)
expect(observer.accessedKeys()).toStrictEqual(['firstname'])
})

Sorry, the diff of this file is not supported yet

import { beforeEach, expect, test } from 'vitest'
import { ref, toValue, isRef, isReactive } from 'vue'
import { setActivePinia, createPinia } from 'pinia'
import { flushPromises } from '@vue/test-utils'
import defineApiStore from '../src/defineApiStore.js'
import mockJsonApi from './JsonApi.mock.js'
const useMockStore = defineApiStore('testUsers', mockJsonApi)
let store = null
beforeEach(() => {
setActivePinia(createPinia())
mockJsonApi.reset()
store = useMockStore()
})
test('can update an element', async () => {
const person1 = store.show(1)
toValue(person1)
await flushPromises()
expect(person1.value.name).toBe('Kevin')
store.update({ id: 1, name: 'Devin' })
await flushPromises()
expect(person1.value.name).toBe('Devin')
})
const mockUsersInitialState = {
1: { id: 1, name: 'Kevin', full_name: 'Kevin Hamer' },
2: { id: 2, name: 'Test', full_name: 'Test test' }
}
let mockUsers = Object.assign({}, mockUsersInitialState)
const mockUserApi = {
index: () => Promise.resolve({
data: Object.values(mockUsers)
}),
get: element => Promise.resolve(mockUsers[element.id]),
reset: () => {
mockUsers = Object.assign({}, mockUsersInitialState)
},
key: (method, params) => {
return `${method}:${JSON.stringify(params)}`
},
post: element => {
mockUsers[element.id] = element
return Promise.resolve(mockUsers[element.id])
},
put: element => {
mockUsers[element.id] = { ...mockUsers[element.id] || {}, ...element }
return Promise.resolve(mockUsers[element.id])
}
}
export default mockUserApi

Sorry, the diff of this file is not supported yet

import { expect, test, vi } from 'vitest'
import JsonApi from '../src/JsonApi.js'
test('can construct a JsonApi instance', () => {
const api = new JsonApi('/api/users')
expect(api).toBeInstanceOf(JsonApi)
})
test('can call index', () => {
JsonApi.config.fetch = vi.fn()
JsonApi.config.fetch.mockResolvedValue({ json: () => ({ data: [] }) })
const api = new JsonApi('/api/users/:id?')
api.index()
expect(JsonApi.config.fetch).toHaveBeenCalledTimes(1)
const [url, options] = JsonApi.config.fetch.mock.lastCall
expect(options.method).toBe('get')
expect(url).toBe('/api/users')
})
test('can call get', () => {
JsonApi.config.fetch = vi.fn()
JsonApi.config.fetch.mockResolvedValue({ json: () => ({ data: { name: 'kevin' } }) })
const api = new JsonApi('/api/users/:id?')
api.get({ id: 3 }).then(u => expect(u.name).toBe('kevin'))
expect(JsonApi.config.fetch).toHaveBeenCalledTimes(1)
const [url, options] = JsonApi.config.fetch.mock.lastCall
expect(options.method).toBe('get')
expect(url).toBe('/api/users/3')
})
test('can work with URLs ending with .json', () => {
JsonApi.config.fetch = vi.fn()
JsonApi.config.fetch.mockResolvedValue({ json: () => ({ data: { name: 'kevin' } }) })
const api = new JsonApi('/api/users/{:id.json}?')
api.get({ id: 3 }).then(u => expect(u.name).toBe('kevin'))
expect(JsonApi.config.fetch).toHaveBeenCalledTimes(1)
const [url, options] = JsonApi.config.fetch.mock.lastCall
expect(options.method).toBe('get')
expect(url).toBe('/api/users/3.json')
})
test('can post', () => {
JsonApi.config.fetch = vi.fn()
JsonApi.config.fetch.mockResolvedValue({ json: () => ({ data: { id: 1, name: 'kevin' } }) })
const api = new JsonApi('/api/users/:id?')
api.store({ name: 'kevin' }).then(u => expect(u.id).toBe(1))
expect(JsonApi.config.fetch).toHaveBeenCalledTimes(1)
const [url, options] = JsonApi.config.fetch.mock.lastCall
expect(options.method).toBe('post')
expect(url).toBe('/api/users')
})
test('can update', () => {
JsonApi.config.fetch = vi.fn()
JsonApi.config.fetch.mockResolvedValue({ json: () => ({ data: { id: 1, name: 'kevin hamer' } }) })
const api = new JsonApi('/api/users/:id?')
api.update({ id: 1, name: 'kevin hamer' }).then(u => {
expect(u.id).toBe(1)
expect(u.name).toBe('kevin hamer')
})
expect(JsonApi.config.fetch).toHaveBeenCalledTimes(1)
const [url, options] = JsonApi.config.fetch.mock.lastCall
expect(options.method).toBe('put')
expect(url).toBe('/api/users/1')
})
import { expect, test, vi } from 'vitest'
import UrlExp from '../src/UrlExp.js'
test('can construct a static UrlExp', () => {
const urlexp = new UrlExp('/api/users')
expect(urlexp.format()).toBe('/api/users')
})
test('can construct a dynamic UrlExp', () => {
const urlexp = new UrlExp('/api/users/:user')
expect(urlexp.format({ user: 3 })).toBe('/api/users/3')
})
test('can construct a dynamic UrlExp with query params', () => {
const urlexp = new UrlExp('/api/users/:user')
expect(urlexp.format({ user: 3, dir: 'asc' })).toBe('/api/users/3?dir=asc')
})
test('only appends required params', () => {
const urlexp = new UrlExp('/api/users/:user')
expect(urlexp.format({ user: 3 }, { ignored: 'field' })).toBe('/api/users/3')
})
test('can use optional params', () => {
const urlexp = new UrlExp('/api/users/:user')
expect(urlexp.format({}, { user: 3, ignored: 'field' })).toBe('/api/users/3')
})
+4
-1
{
"name": "clockvine",
"version": "2.0.0-alpha.8",
"version": "2.0.0-alpha.10",
"description": "",

@@ -37,3 +37,6 @@ "author": "Kevin Hamer [kh] <kevin@imarc.com>",

"vitest": "^0.24.3"
},
"dependencies": {
"path-to-regexp": "^6.2.1"
}
}
+1
-5

@@ -1,7 +0,3 @@

export { default as DefaultUrlFormatter } from './DefaultUrlFormatter.js'
export { default as JsonApi } from './JsonApi.js'
export { default as JsonSingletonApi } from './JsonSingletonApi.js'
export { default as SingletonUrlFormatter } from './SingletonUrlFormatter.js'
export { default as UrlFormatter } from './UrlFormatter.js'
export { default as XsrfFetch } from './XsrfFetch.js'
export { default as defineApiStore } from './defineApiStore.js'
export { default as useXsrfTokens } from './useXsrfTokens.js'
import { defineStore } from 'pinia'
import { reactive, toRef, computed, unref } from 'vue'
import { reactive, toRef, computed, toValue } from 'vue'
import JsonApi from './JsonApi'
const nestedUnref = obj => {
const result = Object.fromEntries(Object.entries(unref(obj)).map(([k, v]) => [k, unref(v)]))
const nestedToValue = (obj) => {
const result = Object.fromEntries(
Object.entries(toValue(obj)).map(([k, v]) => [k, toValue(v)])
)
return result

@@ -15,15 +17,20 @@ }

export default function defineApiStore (
const defineApiStore = function defineApiStore (
name,
api,
{
idField = 'id',
indexDataField = 'data',
showRequiresKey = true
} = {}
idField = defineApiStore.config.idField,
indexDataField = defineApiStore.config.indexDataField,
showRequiresKey = defineApiStore.config.showRequiresKey
} = {},
apiActions = {}
) {
if (typeof api === 'string' || typeof api === 'function') {
api = new JsonApi(api)
if (typeof name !== 'string') {
throw new Error('Store name must be a string.')
}
if (typeof api === 'string') {
api = new JsonApi(api, defineApiStore.config.ApiOptions)
}
return defineStore(name, () => {

@@ -46,2 +53,7 @@ /**

/**
* Custom actions.
*/
const actions = {}
// =========================================================================

@@ -74,4 +86,3 @@ // = Low Level

const mergeElement = (key, element) => {
const oldElement = unref(elements[key]) === undefined ? {} : unref(elements[key])
elements[key] = Object.assign(oldElement, element)
elements[key] = Object.assign(elements[key] ?? {}, element)
elementState[key] = VALID

@@ -87,7 +98,4 @@ return toRef(elements, key)

*/
const mergeElements = elements => {
if (!elements.map) {
console.error('elements.map not defined', elements)
}
return elements.map(element => mergeElement(element[idField], element))
const mergeElements = (elements) => {
return elements.map((element) => mergeElement(element[idField], element))
}

@@ -104,3 +112,8 @@

const setIndex = (key, index) => {
index[indexDataField] = mergeElements(index[indexDataField]).map(unref)
if (!index[indexDataField]) {
throw new Error(`Index must have a '${indexDataField}' field`)
} else if (typeof index[indexDataField].map !== 'function') {
throw new Error(`Index '${indexDataField}' field must be an array`)
}
index[indexDataField] = mergeElements(index[indexDataField]).map(toValue)
indexes[key] = index

@@ -115,8 +128,8 @@ return toRef(indexes, key)

/**
* @param {ref|mixed} idRef
* @return {ref} computed reference to elements[id]
*/
const show = idRef => {
* @param {ref|mixed} idRef
* @return {ref} computed reference to elements[id]
*/
const show = (idRef) => {
return computed(() => {
const id = unref(idRef)
const id = toValue(idRef)
if (showRequiresKey && (id === undefined || id === null)) {

@@ -129,3 +142,3 @@ return

elementState[id] = LOADING
api.show(id).then(element => {
api.get({ [idField]: id }).then((element) => {
const newElement = mergeElement(id, element)

@@ -144,4 +157,4 @@ elementState[id] = VALID

*/
const invalidate = elementOrIdRef => {
let elementOrId = unref(elementOrIdRef)
const invalidate = (elementOrIdRef) => {
let elementOrId = toValue(elementOrIdRef)
if (typeof elementOrId === 'object' && idField in elementOrId) {

@@ -152,11 +165,13 @@ elementOrId = elementOrId[idField]

elementState[elementOrId] = INVALID
return Promise.resolve()
}
/**
* @param {ref|object<ref>} params
* @return {ref} computed reference to elements[id]
*/
* @param {ref|object<ref>} params
* @return {ref} computed reference to elements[id]
*/
const indexAsRef = (paramsRef = {}) => {
return computed(() => {
const params = nestedUnref(paramsRef)
const params = nestedToValue(paramsRef)
const key = api.key('index', params)

@@ -167,3 +182,3 @@

indexState[key] = LOADING
api.index(params).then(index => {
api.index(params).then((index) => {
const newIndex = setIndex(key, index)

@@ -180,6 +195,8 @@ indexState[key] = VALID

const invalidateIndex = (paramsRef = {}) => {
const params = nestedUnref(paramsRef)
const params = nestedToValue(paramsRef)
const key = api.key('index', params)
indexState[key] = INVALID
return Promise.resolve()
}

@@ -191,25 +208,30 @@

}
return Promise.resolve()
}
const index = (paramsRef = {}) => {
return new Proxy({}, {
get (_, prop) {
return computed(() => {
const params = nestedUnref(paramsRef)
const key = api.key('index', params)
return new Proxy(
{},
{
get (_, prop) {
return computed(() => {
const params = nestedToValue(paramsRef)
const key = api.key('index', params)
if (!(key in indexState) || indexState[key] === INVALID) {
indexes[key] = indexes[key] || reactive({})
indexState[key] = LOADING
api.index(params).then(index => {
const newIndex = setIndex(key, index)
indexState[key] = VALID
return newIndex
})
}
if (!(key in indexState) || indexState[key] === INVALID) {
indexes[key] = indexes[key] || reactive({})
indexState[key] = LOADING
api.index(params).then((index) => {
const newIndex = setIndex(key, index)
indexState[key] = VALID
return newIndex
})
}
return indexes[key][prop]
})
return indexes[key][prop]
})
}
}
})
)
}

@@ -222,3 +244,3 @@

const store = async (element, params = {}) => {
const newElement = await api.store(nestedUnref(element), params)
const newElement = await api.post(nestedToValue(element), params)
invalidateAllIndexes()

@@ -229,4 +251,5 @@ return mergeElement(newElement[idField], newElement)

const update = async (element, params = {}) => {
const updatedElement = await api.update(nestedUnref(element), params)
const id = idField in updatedElement ? updatedElement[idField] : element[idField]
const updatedElement = await api.put(nestedToValue(element), params)
const id =
idField in updatedElement ? updatedElement[idField] : element[idField]
invalidateAllIndexes()

@@ -237,3 +260,3 @@ return mergeElement(id, updatedElement)

const destroy = async (element, params = {}) => {
await api.destroy(nestedUnref(element), params)
await api.destroy(nestedToValue(element), params)
invalidateAllIndexes()

@@ -243,2 +266,30 @@ return deleteElement(element)

const defineAction = async (
action,
{ apiAction = action, invalidateIndexes = false, mergeElement = true }
) => {
actions[action] = async (element, params = {}) => {
const updatedElement = await api[apiAction](
nestedToValue(element),
params
)
if (invalidateIndexes) {
invalidateAllIndexes()
}
const id =
idField in updatedElement
? updatedElement[idField]
: element[idField]
if (mergeElement && id) {
return mergeElement(id, updatedElement)
} else {
return updatedElement
}
}
}
Object.entries(apiActions).forEach(([action, options]) =>
defineAction(action, options)
)
return {

@@ -254,2 +305,3 @@ /**

indexState,
actions,

@@ -264,5 +316,18 @@ destroy,

store,
update
update,
...actions
}
})
}
defineApiStore.config = {
idField: 'id',
indexDataField: 'data',
showRequiresKey: true,
ApiOptions: undefined
}
defineApiStore.use = (plugin) => plugin(defineApiStore)
export default defineApiStore

@@ -1,57 +0,62 @@

import DefaultUrlFormatter from './DefaultUrlFormatter.js'
import UrlExp from './UrlExp.js'
export default function JsonApi (baseUrl, {
fetch = window.fetch,
Formatter = DefaultUrlFormatter,
serialize = JSON.stringify
const JsonApi = function JsonApi (urlExp, {
fetch = JsonApi.config.fetch,
headers = JsonApi.config.headers,
serialize = JsonApi.config.serialize
} = {}) {
const createQueryUrl = (new Formatter(baseUrl)).format
this.key = createQueryUrl
this.index = async function (params) {
const url = createQueryUrl('index', params)
return fetch(url).then(r => r.json())
if (!(urlExp instanceof UrlExp)) {
if (typeof urlExp === 'string') {
urlExp = new UrlExp(urlExp)
} else {
throw new TypeError('urlExp must be a UrlExp or string')
}
}
this.show = async function (id) {
const url = createQueryUrl('show', { id })
return fetch(url).then(r => r.json()).then(r => r.data)
}
this.store = async function (element, params = {}) {
const url = createQueryUrl('store', params, element)
const options = {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: serialize(element)
const makeAction = function (
method,
{
beforeFetch = options => options,
afterFetch = r => r.json()
} = {}
) {
return async (element, params = {}) => {
const url = urlExp.format(params, element)
const options = { url, method, headers }
if (!['get', 'head'].includes(method.toLowerCase())) {
options.body = serialize(element)
}
beforeFetch(options)
return fetch(options.url, options).then(afterFetch)
}
return fetch(url, options).then(r => r.json()).then(r => r.data)
}
this.update = async function (element, params = {}) {
const url = createQueryUrl('update', params, element)
const options = {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: serialize(element)
}
return fetch(url, options).then(r => r.json()).then(r => r.data)
this.defineAction = function (action, method = action, ...args) {
this[action] = (element, params = {}) => makeAction(method, ...args)(element, params).then(r => r.data)
}
this.destroy = async function (element, params = {}) {
const url = createQueryUrl('destroy', params, element)
const options = {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
body: serialize(element)
}
return fetch(url, options).then(r => r.json()).then(r => r.data)
}
this.key = params => urlExp.format(params)
this.defineAction('delete')
this.destroy = this.delete
this.defineAction('put')
this.update = this.put
this.defineAction('post')
this.store = this.post
this.defineAction('get')
this.show = this.get
this.index = this.get
}
JsonApi.config = {
fetch,
headers: { 'Content-Type': 'application/json' },
serialize: JSON.stringify
}
JsonApi.use = plugin => plugin(JsonApi)
export default JsonApi

@@ -1,147 +0,41 @@

import { ref, computed, isRef, watch } from 'vue'
import { beforeEach, expect, test, vi } from 'vitest'
import { userApiReset, mockUserApi, testUserStore, vueUpdates, ensureLoaded } from './testHelpers.js'
import { beforeEach, expect, test } from 'vitest'
import { ref, toValue, isRef, isReactive } from 'vue'
import { setActivePinia, createPinia } from 'pinia'
import { flushPromises } from '@vue/test-utils'
beforeEach(userApiReset)
import defineApiStore from '../src/defineApiStore.js'
import mockJsonApi from './JsonApi.mock.js'
test('loading an index calls api.index', () => {
const indexSpy = vi.spyOn(mockUserApi, 'index')
const store = testUserStore()
const useMockStore = defineApiStore('testUsers', mockJsonApi)
let store = null
expect(indexSpy).toHaveBeenCalledTimes(0)
// necessary for to trigger computing the index at all
ensureLoaded(store.index().data)
expect(indexSpy).toHaveBeenCalledTimes(1)
beforeEach(() => {
setActivePinia(createPinia())
mockJsonApi.reset()
store = useMockStore()
})
test('loading an index more than once only calls api.index once', () => {
const indexSpy = vi.spyOn(mockUserApi, 'index')
const store = testUserStore()
test('can load an index', async () => {
const index = store.index()
ensureLoaded(store.index().data)
ensureLoaded(store.index().data)
toValue(index.data)
await flushPromises()
expect(indexSpy).toHaveBeenCalledTimes(1)
expect(toValue(index.data).length).toBe(2)
})
test('invalidating an index will recall api.index', async () => {
const indexSpy = vi.spyOn(mockUserApi, 'index')
const userStore = testUserStore()
test('index is reactive', async () => {
const index = store.index()
const { data: users } = userStore.index()
toValue(index.data)
await flushPromises()
watch(users, () => {})
userStore.invalidateIndex()
await vueUpdates()
expect(toValue(index.data).length).toBe(2)
expect(indexSpy).toHaveBeenCalledTimes(2)
})
store.store({ id: 3, name: 'Chuck', full_name: 'Chuck Norris' })
await flushPromises()
toValue(index.data)
await flushPromises()
test('parameters are passed through to api.index', () => {
const indexSpy = vi.spyOn(mockUserApi, 'index')
const store = testUserStore()
ensureLoaded(store.index({ foo: 'bar', bin: 'baz' }).data)
expect(indexSpy).toHaveBeenCalledWith({ foo: 'bar', bin: 'baz' })
expect(toValue(index.data).length).toBe(3)
})
test('can call with different sets of parameters', async () => {
const indexSpy = vi.spyOn(mockUserApi, 'index')
const store = testUserStore()
ensureLoaded(store.index({ foo: 'bar', bin: 'baz' }).data)
expect(indexSpy).toHaveBeenCalledWith({ foo: 'bar', bin: 'baz' })
ensureLoaded(store.index({ foo: 'alpha' }).data)
await vueUpdates()
expect(indexSpy).toHaveBeenCalledWith({ foo: 'alpha' })
})
test('parameter properties can be reactive', async () => {
const indexSpy = vi.spyOn(mockUserApi, 'index')
const store = testUserStore()
const foo = ref('bar')
const { users } = store.index({ foo })
ensureLoaded(users)
expect(indexSpy).toHaveBeenCalledWith({ foo: 'bar' })
foo.value = 'biz'
ensureLoaded(users)
expect(indexSpy).toHaveBeenCalledWith({ foo: 'biz' })
})
test('Parameters itself can be reactive', async () => {
const indexSpy = vi.spyOn(mockUserApi, 'index')
const store = testUserStore()
const foo = ref('bar')
const params = computed(() => ({ foo }))
const { data: users } = store.index(params)
ensureLoaded(users)
expect(indexSpy).toHaveBeenCalledWith({ foo: 'bar' })
foo.value = 'biz'
ensureLoaded(users)
expect(indexSpy).toHaveBeenCalledWith({ foo: 'biz' })
})
test('the returned value is reactive', async () => {
const indexSpy = vi.spyOn(mockUserApi, 'index')
const store = testUserStore()
const { data: users } = store.index()
expect(users.value).toBe(undefined)
await vueUpdates()
expect(indexSpy).toHaveBeenCalledTimes(1)
expect(users.value.length).toBe(2)
})
test('api.show results merge over api.index', async () => {
const store = testUserStore()
ensureLoaded(store.show(1))
const { data: users } = store.index()
ensureLoaded(users)
await vueUpdates()
expect(users.value[0].shown).toBeTruthy()
})
test('index returns references', async () => {
const store = testUserStore()
const { data } = store.index()
expect(isRef(data)).toBeTruthy()
ensureLoaded(data)
await vueUpdates()
expect(data.value.length).toBe(2)
})
test('index references work properly', async () => {
const store = testUserStore()
const { data } = store.index()
watch(data, () => {})
await vueUpdates()
expect(data.value.length).toBe(2)
await store.store({ id: 10, name: 'Cheese', full_name: 'Brie' })
await vueUpdates()
expect(data.value.length).toBe(3)
})

@@ -1,59 +0,70 @@

import { watch } from 'vue'
import { beforeEach, expect, test, vi } from 'vitest'
import { userApiReset, mockUserApi, testUserStore, vueUpdates, ensureLoaded } from './testHelpers.js'
import { beforeEach, expect, test } from 'vitest'
import { ref, toValue } from 'vue'
import { setActivePinia, createPinia } from 'pinia'
import { flushPromises } from '@vue/test-utils'
beforeEach(userApiReset)
import defineApiStore from '../src/defineApiStore.js'
import mockJsonApi from './JsonApi.mock.js'
test('ref is reactive', async () => {
const showSpy = vi.spyOn(mockUserApi, 'show')
const store = testUserStore()
const useMockStore = defineApiStore('testUsers', mockJsonApi)
let store = null
beforeEach(() => {
setActivePinia(createPinia())
mockJsonApi.reset()
store = useMockStore()
})
test('can get by ID', async () => {
const person1 = store.show(1)
expect(person1.value).toBe(undefined)
toValue(person1)
await flushPromises()
await vueUpdates()
expect(person1.value.name).toBe('Kevin')
})
expect(showSpy).toHaveBeenCalledTimes(1)
test('calls API', async () => {
const person1 = store.show(1)
expect(person1?.value.name).toBe('Kevin')
await flushPromises()
})
test('Only calls show on Api once', async () => {
const show = vi.spyOn(mockUserApi, 'show')
const store = testUserStore()
test('get same value for multiple refs', async () => {
const person1 = store.show(1)
const person2 = store.show(1)
ensureLoaded(store.show(1))
ensureLoaded(store.show(1))
await flushPromises()
expect(show).toHaveBeenCalledTimes(1) // TODO
expect(person1 === person2).toBeFalsy()
expect(person1.value === person2.value).toBeTruthy()
})
test('ref is mutable', async () => {
const store = testUserStore()
const person1 = store.show(1)
const another1 = store.show(1)
const person2 = store.show(1)
ensureLoaded(person1)
await vueUpdates()
toValue(person1)
toValue(person2)
await flushPromises()
another1.value.name = 'Chuck'
expect(person1?.value.name).toBe('Chuck')
person1.value.name = 'Chuck'
expect(person2.value.name).toBe('Chuck')
})
test('invalidating an element will recall api.show', async () => {
const showSpy = vi.spyOn(mockUserApi, 'show')
const store = testUserStore()
const person1 = store.show(1)
test('ref is reactive to ID changes', async () => {
mockJsonApi.reset()
const id = ref(1)
const person1 = store.show(id)
watch(person1, () => {})
toValue(person1)
await flushPromises()
await vueUpdates()
expect(person1.value.name).toBe('Kevin')
expect(showSpy).toHaveBeenCalledTimes(1)
id.value = 2
toValue(person1)
await flushPromises()
store.invalidate(person1)
await vueUpdates()
expect(showSpy).toHaveBeenCalledTimes(2)
expect(person1.value.name).toBe('Test')
})

@@ -13,3 +13,3 @@ const mockUsersInitialState = {

index: (params) => Promise.resolve({ data: Object.values(mockUsers).map(e => Object.assign({}, e)) }),
show: id => Promise.resolve(id in mockUsers ? Object.assign({ shown: (new Date()) }, mockUsers[id]) : null),
get: id => Promise.resolve(id in mockUsers ? mockUsers[id] : null),
update: element => {

@@ -16,0 +16,0 @@ mockUsers[element.id] = element

const filterKeys = (obj, remove = [null, undefined]) => {
return Object.fromEntries(Object.entries(obj).filter(([_, v]) => !remove.includes(v)))
}
export default function DefaultUrlFormatter (baseUrl) {
this.format = (action, queryParams, payload) => {
const id = queryParams?.id || payload?.id
if (['show', 'update', 'destroy'].includes(action) && ![null, undefined].includes(id)) {
const url = baseUrl.replace(/(\.json)?$/, `/${id}$1`)
const queryString = new URLSearchParams(filterKeys({ ...queryParams, id: undefined }))
return url + (String(queryString) ? '?' + String(queryString) : '')
}
const queryString = new URLSearchParams(filterKeys(queryParams))
return `${baseUrl}${String(queryString) ? '?' + queryString : ''}`
}
}
import SingletonUrlFormatter from './SingletonUrlFormatter.js'
import JsonApi from './JsonApi.js'
export default function JsonSingletonApi (baseUrl, options) {
return new JsonApi(baseUrl, { ...options, Formatter: SingletonUrlFormatter })
}
const filterKeys = (obj, remove = [null, undefined]) => {
return Object.fromEntries(Object.entries(obj).filter(([_, v]) => !remove.includes(v)))
}
export default function SingletonUrlFormatter (baseUrl) {
this.format = (_, queryParams) => {
const queryString = new URLSearchParams(filterKeys(queryParams))
return `${baseUrl}${String(queryString) ? '?' + queryString : ''}`
}
}
/**
* templates should be an object with a 'default' key and additional action-specific keys if you'd like.
*
* {
* default: '/api/endpoint',
* store: ({ id }) => `/api/endoppoint/:id/store`,
* }
*
* This allows for validation/destructuring per action.
*
* @param templates Object
*/
export default function UrlFormatter (templates = {}) {
this.format = (action, queryParams, payload = {}) => {
const template = action in templates ? templates[action] : templates.default
const usedQueryParams = new Set()
const queryParamProxy = new Proxy(payload, {
get: function (_, prop) {
if (prop in queryParams) {
usedQueryParams.add(prop)
return usedQueryParams[prop]
}
return Reflect.get(...arguments)
}
})
const baseUrl = typeof template === 'function' ? template(queryParamProxy) : template
const remainingQueryParams = Object.fromEntries(
Object.entries(queryParams)
.filter(([k, v]) => !usedQueryParams.has(k) && ![null, undefined].includes(v)))
const queryString = new URLSearchParams(remainingQueryParams)
return `${baseUrl}${queryString ? '?' + queryString : ''}`
}
}
const TOKEN_REGEX = /XSRF-TOKEN=([^; ]*)/
export default function XsrfFetch (defaultOptions = {}) {
const headers = new Headers(defaultOptions.headers || {})
if (TOKEN_REGEX.test(document.cookie)) {
const token = decodeURIComponent(document.cookie.match(TOKEN_REGEX)[1])
headers.set('X-XSRF-TOKEN', token)
}
defaultOptions.headers = headers
return (resource, options = {}) => {
const headers = new Headers(defaultOptions.headers)
if (options.headers) {
(new Headers(options.headers)).forEach((value, key) => {
headers.set(key, value)
})
}
return fetch(resource, Object.assign({}, defaultOptions, options, { headers }))
}
}
import { watch } from 'vue'
import { beforeEach, expect, test, vi } from 'vitest'
import { userApiReset, mockUserApi, testUserStore, vueUpdates, ensureLoaded } from './testHelpers.js'
beforeEach(userApiReset)
test('can update', async () => {
const store = testUserStore()
const person1 = store.show(1)
store.update({ id: 1, name: 'Chuck' })
await vueUpdates()
expect(person1.value.name).toBe('Chuck')
})
test('can update from clone object', async () => {
const store = testUserStore()
const person1 = store.show(1)
ensureLoaded(person1)
await vueUpdates()
const draftPerson = Object.assign({}, person1.value)
draftPerson.full_name = 'Kevin "The Coder" Hamer'
store.update(draftPerson)
await vueUpdates()
expect(person1.value.name).toBe('Kevin')
expect(person1.value.full_name).toBe('Kevin "The Coder" Hamer')
})
test('can store a new object', async () => {
const store = testUserStore()
store.store({ id: 3, name: 'Jim', full_name: 'Jim Halpert' })
await vueUpdates()
const person3 = store.show(3)
await vueUpdates()
expect(person3.value.name).toBe('Jim')
})
test('can have a reference to an object before its created', async () => {
const store = testUserStore()
const person3 = store.show(3)
await vueUpdates()
store.store({ id: 3, name: 'Jim', full_name: 'Jim Halpert' })
await vueUpdates()
expect(person3.value.name).toBe('Jim')
})
test('can destroy an object', async () => {
const store = testUserStore()
store.show(1)
await vueUpdates()
store.destroy({ id: 1 })
await vueUpdates()
const person1 = store.show(1)
await vueUpdates()
expect(person1.value).toBe(undefined)
})
test('indexes refresh on store', async () => {
const store = testUserStore()
const { data: users } = store.index()
ensureLoaded(store.show(1))
watch(users, () => {})
await vueUpdates()
expect(users.value.length).toBe(2)
store.store({ id: 3, name: 'Jim', full_name: 'Jim Halpert' })
await vueUpdates()
expect(users.value.length).toBe(3)
})
test('index refresh is lazy', async () => {
const indexSpy = vi.spyOn(mockUserApi, 'index')
const store = testUserStore()
const { data: users } = store.index()
ensureLoaded(store.show(1))
ensureLoaded(users)
await vueUpdates()
expect(indexSpy).toHaveBeenCalledTimes(1)
store.store({ id: 3, name: 'Jim', full_name: 'Jim Halpert' })
await vueUpdates()
expect(indexSpy).toHaveBeenCalledTimes(1)
ensureLoaded(users)
expect(indexSpy).toHaveBeenCalledTimes(2)
})