| 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' |
+119
-54
| 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 |
+52
-47
@@ -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) | ||
| }) |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
38611
52.94%28
64.71%742
14.86%1
-50%3
50%29
262.5%+ Added
+ Added