refactor: turn data store to global state with cache handing

This commit is contained in:
axolotle 2024-08-05 23:09:35 +02:00
parent 66f48649d2
commit 13dc8de182
4 changed files with 268 additions and 310 deletions

View file

@ -1,3 +1,4 @@
import { useCache, type StorePath } from '@/composables/data'
import { useInfos } from '@/composables/useInfos' import { useInfos } from '@/composables/useInfos'
import { import {
useRequests, useRequests,
@ -19,8 +20,7 @@ export type HumanKey = {
export type APIQuery = { export type APIQuery = {
method?: RequestMethod method?: RequestMethod
uri: string uri: string
cachePath?: string cachePath?: StorePath
cacheParams?: Obj
data?: Obj data?: Obj
humanKey?: string | HumanKey humanKey?: string | HumanKey
showModal?: boolean showModal?: boolean
@ -95,11 +95,10 @@ export default {
* @returns Promise that resolve the api response data * @returns Promise that resolve the api response data
* @throws Throw an `APIError` or subclass depending on server response * @throws Throw an `APIError` or subclass depending on server response
*/ */
async fetch<T extends any = Obj | string>({ async fetch<T extends any = any>({
uri, uri,
method = 'GET', method = 'GET',
cachePath = undefined, cachePath = undefined,
cacheParams = undefined,
data = undefined, data = undefined,
humanKey = undefined, humanKey = undefined,
showModal = method !== 'GET', showModal = method !== 'GET',
@ -107,6 +106,9 @@ export default {
initial = false, initial = false,
asFormData = true, asFormData = true,
}: APIQuery): Promise<T> { }: APIQuery): Promise<T> {
const cache = cachePath ? useCache<T>(method, cachePath) : undefined
if (method === 'GET' && cache?.content) return cache.content
const { locale } = useSettings() const { locale } = useSettings()
const { startRequest, endRequest } = useRequests() const { startRequest, endRequest } = useRequests()
@ -136,14 +138,18 @@ export default {
} }
const response = await fetch('/yunohost/api/' + uri, options) const response = await fetch('/yunohost/api/' + uri, options)
const responseData = await getResponseData(response)
endRequest({ request, success: response.ok })
if (!response.ok) { if (!response.ok) {
throw getError(request, response, responseData as string | APIErrorData) const errorData = await getResponseData<string | APIErrorData>(response)
endRequest({ request, success: false })
throw getError(request, response, errorData)
} }
return responseData as T const responseData = await getResponseData<T>(response)
cache?.update(responseData)
endRequest({ request, success: true })
return responseData
}, },
/** /**
@ -158,18 +164,18 @@ export default {
* @returns Promise that resolves an array of server responses * @returns Promise that resolves an array of server responses
* @throws Throw an `APIError` or subclass depending on server response * @throws Throw an `APIError` or subclass depending on server response
*/ */
async fetchAll( async fetchAll<T extends any[] = any[]>(
queries: APIQuery[], queries: APIQuery[],
{ showModal = false, initial = false } = {}, { showModal = false, initial = false } = {},
) { ): Promise<T> {
const results: Array<Obj | string> = [] const results = []
for (const query of queries) { for (const query of queries) {
if (showModal) query.showModal = true if (showModal) query.showModal = true
if (initial) query.initial = true if (initial) query.initial = true
results.push(await this.fetch(query)) results.push(await this.fetch(query))
} }
return results return results as T
}, },
/** /**
@ -180,7 +186,7 @@ export default {
* @returns Promise that resolve the api response data or an error * @returns Promise that resolve the api response data or an error
* @throws Throw an `APIError` or subclass depending on server response * @throws Throw an `APIError` or subclass depending on server response
*/ */
get<T extends any = Obj | string>( get<T extends any = any>(
query: string | Omit<APIQuery, 'method' | 'data'>, query: string | Omit<APIQuery, 'method' | 'data'>,
): Promise<T> { ): Promise<T> {
return this.fetch(typeof query === 'string' ? { uri: query } : query) return this.fetch(typeof query === 'string' ? { uri: query } : query)
@ -194,9 +200,7 @@ export default {
* @returns Promise that resolve the api response data or an error * @returns Promise that resolve the api response data or an error
* @throws Throw an `APIError` or subclass depending on server response * @throws Throw an `APIError` or subclass depending on server response
*/ */
post<T extends any = Obj | string>( post<T extends any = any>(query: Omit<APIQuery, 'method'>): Promise<T> {
query: Omit<APIQuery, 'method'>,
): Promise<T> {
return this.fetch({ ...query, method: 'POST' }) return this.fetch({ ...query, method: 'POST' })
}, },
@ -208,9 +212,7 @@ export default {
* @returns Promise that resolve the api response data or an error * @returns Promise that resolve the api response data or an error
* @throws Throw an `APIError` or subclass depending on server response * @throws Throw an `APIError` or subclass depending on server response
*/ */
put<T extends any = Obj | string>( put<T extends any = any>(query: Omit<APIQuery, 'method'>): Promise<T> {
query: Omit<APIQuery, 'method'>,
): Promise<T> {
return this.fetch({ ...query, method: 'PUT' }) return this.fetch({ ...query, method: 'PUT' })
}, },
@ -222,9 +224,7 @@ export default {
* @returns Promise that resolve the api response data or an error * @returns Promise that resolve the api response data or an error
* @throws Throw an `APIError` or subclass depending on server response * @throws Throw an `APIError` or subclass depending on server response
*/ */
delete<T extends any = Obj | string>( delete<T extends any = any>(query: Omit<APIQuery, 'method'>): Promise<T> {
query: Omit<APIQuery, 'method'>,
): Promise<T> {
return this.fetch({ ...query, method: 'DELETE' }) return this.fetch({ ...query, method: 'DELETE' })
}, },

View file

@ -20,13 +20,15 @@ import type { APIErrorData } from './api'
* @param response - A fetch `Response` object. * @param response - A fetch `Response` object.
* @returns Parsed response's json or response's text. * @returns Parsed response's json or response's text.
*/ */
export async function getResponseData(response: Response) { export async function getResponseData<T extends any = any>(
response: Response,
): Promise<T> {
// FIXME the api should always return json as response // FIXME the api should always return json as response
const responseText = await response.text() const responseText = await response.text()
try { try {
return JSON.parse(responseText) as Obj return JSON.parse(responseText)
} catch { } catch {
return responseText return responseText as T
} }
} }

View file

@ -1,312 +1,224 @@
import api from '@/api' import { createGlobalState } from '@vueuse/core'
import { isEmptyValue } from '@/helpers/commons' import { computed, reactive, ref, toValue, type MaybeRefOrGetter } from 'vue'
import type { RequestMethod } from '@/api/api'
import { isObjectLiteral } from '@/helpers/commons'
import { stratify } from '@/helpers/data/tree' import { stratify } from '@/helpers/data/tree'
import { reactive } from 'vue' import type { Obj } from '@/types/commons'
import type {
DomainDetail,
Group,
Permission,
UserDetails,
UserItem,
} from '@/types/core/data'
export function getParentDomain(domain, domains, highest = false) { function arrayOrNull<T extends any[]>(items: T): T | null {
const method = highest ? 'lastIndexOf' : 'indexOf' return items.length ? items : null
let i = domain[method]('.')
while (i !== -1) {
const dn = domain.slice(i + 1)
if (domains.includes(dn)) return dn
i = domain[method]('.', i + (highest ? -1 : 1))
}
return null
} }
export default { const useData = createGlobalState(() => {
state: () => ({ const users = ref<Obj<UserItem>>({})
main_domain: undefined, const userDetails = ref<Obj<UserDetails>>({})
domains: undefined, // Array const groups = ref<Obj<Group>>({})
domains_details: {}, const permissions = ref<Obj<Permission>>({})
users: undefined, // basic user data: Object {username: {data}} const mainDomain = ref<string | undefined>()
users_details: {}, // precise user data: Object {username: {data}} const domains = ref<string[] | undefined>()
groups: undefined, const domainDetails = ref<Obj<DomainDetail>>({})
permissions: undefined,
}),
mutations: { function update(
SET_DOMAINS(state, [{ domains, main }]) { method: RequestMethod,
state.domains = domains payload: any,
state.main_domain = main key: DataKeys,
}, param?: string,
) {
SET_DOMAINS_DETAILS(state, [name, details]) { if (key === 'users') {
state.domains_details[name] = details if (method === 'GET') users.value = payload.users
}, else if (method === 'POST')
users.value[payload.username] = {
UPDATE_DOMAINS_DETAILS(state, payload) { ...payload,
// FIXME use a common function to execute the same code ? 'mailbox-quota': 'Pas de quota',
this.commit('SET_DOMAINS_DETAILS', payload) groups: [],
}, }
} else if (key === 'userDetails' && param) {
DEL_DOMAINS_DETAILS(state, [name]) { if (method === 'GET' || method === 'PUT') {
delete state.domains_details[name] userDetails.value[param] = payload[param]
if (state.domains) { } else if (method === 'DELETE') {
delete state.domains[name] delete userDetails.value[param]
delete users.value[param]
} }
}, } else if (key === 'permissions') {
if (method === 'GET') {
ADD_DOMAINS(state, [{ domain }]) { permissions.value = payload.permissions
state.domains.push(domain) } else if (method === 'PUT' && param) {
}, permissions.value[param] = payload
}
DEL_DOMAINS(state, [domain]) { } else if (key === 'groups') {
state.domains.splice(state.domains.indexOf(domain), 1) if (method === 'GET') {
}, groups.value = payload.groups
} else if (method === 'POST') {
// Now applied thru 'SET_DOMAINS' groups.value[payload.name] = { members: [], permissions: [] }
// 'SET_MAIN_DOMAIN' (state, [response]) { } else if (method === 'PUT' && param) {
// state.main_domain = response.current_main_domain groups.value[param] = payload
// }, } else if (method === 'DELETE' && param) {
delete groups.value[param]
UPDATE_MAIN_DOMAIN(state, [domain]) { }
state.main_domain = domain } else if (key === 'domains') {
}, if (method === 'GET') {
domains.value = payload.domains
SET_USERS(state, [users]) { mainDomain.value = payload.main
state.users = users || null } else if (param) {
}, if (method === 'POST') {
// FIXME api should at least return the domain name on
ADD_USERS(state, [user]) { domains.value?.push(param)
if (!state.users) state.users = {} } else if (method === 'PUT') {
state.users[user.username] = user mainDomain.value = param
}, } else if (method === 'DELETE') {
domains.value?.splice(domains.value.indexOf(param), 1)
SET_USERS_DETAILS(state, [username, userData]) { delete domainDetails.value[param]
state.users_details[username] = userData
if (!state.users) return
const user = state.users[username]
for (const key of ['fullname', 'mail']) {
if (user[key] !== userData[key]) {
user[key] = userData[key]
} }
} }
}, } else if (key === 'domainDetails' && param && method === 'GET') {
domainDetails.value[param] = payload
}
UPDATE_USERS_DETAILS(state, payload) { // FIXME rm?
// FIXME use a common function to execute the same code ? throw new Error(
this.commit('SET_USERS_DETAILS', payload) `couldnt update the cache, key: ${key}, method: ${method}, param: ${param}`,
}, )
}
DEL_USERS_DETAILS(state, [username]) { return {
delete state.users_details[username] users,
if (state.users) { userDetails,
delete state.users[username] groups,
if (Object.keys(state.users).length === 0) { permissions,
state.users = null
}
}
},
SET_GROUPS(state, [groups]) { mainDomain,
state.groups = groups domains,
}, domainDetails,
ADD_GROUPS(state, [{ name }]) { update,
if (state.groups !== undefined) { }
state.groups[name] = { members: [], permissions: [] } })
}
},
UPDATE_GROUPS(state, [data, { groupName }]) { export function useUsersAndGroups(username?: MaybeRefOrGetter<string>) {
state.groups[groupName] = data const { users, userDetails } = useData()
}, return {
users: computed(() => {
return arrayOrNull(Object.values(users.value))
}),
usernames: computed(() => {
return arrayOrNull(Object.keys(users.value))
}),
user: computed(() => {
if (!username) return
return userDetails.value[toValue(username)]
}),
}
}
DEL_GROUPS(state, [groupname]) { export function useDomains(domain_?: MaybeRefOrGetter<string>) {
delete state.groups[groupname] const { mainDomain, domains, domainDetails } = useData()
},
SET_PERMISSIONS(state, [permissions]) { const orderedDomains = computed(() => {
state.permissions = permissions if (!domains.value) return
},
UPDATE_PERMISSIONS(state, [_, { groupName, action, permId }]) { const splittedDomains = Object.fromEntries(
// FIXME hacky way to update the store domains.value.map((domain) => {
const permissions = state.groups[groupName].permissions // Keep the main part of the domain and the extension together
if (action === 'add') { // eg: this.is.an.example.com -> ['example.com', 'an', 'is', 'this']
permissions.push(permId) const domainParts = domain.split('.')
} else if (action === 'remove') { domainParts.push(domainParts.pop()! + domainParts.pop()!)
const index = permissions.indexOf(permId) return [domain, domainParts.reverse()]
if (index > -1) permissions.splice(index, 1) }),
} )
},
},
actions: { return domains.value.sort((a, b) =>
GET( splittedDomains[a] > splittedDomains[b] ? 1 : -1,
{ state, commit, rootState }, )
{ })
uri,
param,
storeKey = uri,
humanKey,
noCache,
options,
...extraParams
},
) {
const currentState = param ? state[storeKey][param] : state[storeKey]
// if data has already been queried, simply return
const ignoreCache = !rootState.cache || noCache || false
if (currentState !== undefined && !ignoreCache) return currentState
return api
.fetch('GET', param ? `${uri}/${param}` : uri, null, humanKey, options)
.then((responseData) => {
// FIXME here's an ugly fix to be able to also cache the main domain when querying domains
const data =
storeKey === 'domains'
? responseData
: responseData[storeKey]
? responseData[storeKey]
: responseData
commit(
'SET_' + storeKey.toUpperCase(),
[param, data, extraParams].filter((item) => !isEmptyValue(item)),
)
return param ? state[storeKey][param] : state[storeKey]
})
},
POST( return {
{ state, commit }, mainDomain,
{ uri, storeKey = uri, data, humanKey, options, ...extraParams }, domain: computed(() => {
) { if (!domain_) return
return api return domainDetails.value[toValue(domain_)]
.fetch('POST', uri, data, humanKey, options) }),
.then((responseData) => { domains,
// FIXME api/domains returns null domainsAsChoices: computed(() => {
if (responseData === null) responseData = data return domains.value?.map((domain) => ({
responseData = responseData[storeKey] value: domain,
? responseData[storeKey] text: domain === mainDomain.value ? domain + ' ★' : domain,
: responseData }))
commit( }),
'ADD_' + storeKey.toUpperCase(), orderedDomains,
[responseData, extraParams].filter((item) => !isEmptyValue(item)), domainsTree: computed(() => {
) const domains = orderedDomains.value
return state[storeKey]
})
},
PUT(
{ state, commit },
{ uri, param, storeKey = uri, data, humanKey, options, ...extraParams },
) {
return api
.fetch('PUT', param ? `${uri}/${param}` : uri, data, humanKey, options)
.then((responseData) => {
const data = responseData[storeKey]
? responseData[storeKey]
: responseData
commit(
'UPDATE_' + storeKey.toUpperCase(),
[param, data, extraParams].filter((item) => !isEmptyValue(item)),
)
return param ? state[storeKey][param] : state[storeKey]
})
},
DELETE(
{ commit },
{ uri, param, storeKey = uri, data, humanKey, options, ...extraParams },
) {
return api
.fetch(
'DELETE',
param ? `${uri}/${param}` : uri,
data,
humanKey,
options,
)
.then(() => {
commit(
'DEL_' + storeKey.toUpperCase(),
[param, extraParams].filter((item) => !isEmptyValue(item)),
)
})
},
RESET_CACHE_DATA({ state }, keys = Object.keys(state)) {
for (const key of keys) {
if (key === 'users_details') {
state[key] = {}
} else {
state[key] = undefined
}
}
},
},
getters: {
users: (state) => {
if (state.users) return Object.values(state.users)
return state.users
},
userNames: (state) => {
if (state.users) return Object.keys(state.users)
return []
},
user: (state) => (name) => state.users_details[name], // not cached
domains: (state) => state.domains,
orderedDomains: (state) => {
if (!state.domains) return
const splittedDomains = Object.fromEntries(
state.domains.map((domain) => {
// Keep the main part of the domain and the extension together
// eg: this.is.an.example.com -> ['example.com', 'an', 'is', 'this']
domain = domain.split('.')
domain.push(domain.pop() + domain.pop())
return [domain, domain.reverse()]
}),
)
return state.domains.sort(
(a, b) => splittedDomains[a] > splittedDomains[b],
)
},
domainsTree: (state, getters) => {
// This getter will not return any reactive data, make sure to assign its output
// to a component's `data`.
// FIXME manage to store the result in the store to allow reactive data (trigger an
// action when state.domain change)
const domains = getters.orderedDomains
if (!domains) return if (!domains) return
const dataset = reactive( const dataset = reactive(
domains.map((name) => ({ domains.map((domain) => ({
// data to build a hierarchy // data to build a hierarchy
name, name: domain,
parent: getParentDomain(name, domains), parent: domainDetails.value[domain].topest_parent,
// utility data that will be used by `RecursiveListGroup` component // utility data that will be used by `RecursiveListGroup` component
to: { name: 'domain-info', params: { name } }, to: { name: 'domain-info', params: { name: domain } },
opened: true, opened: true,
})), })),
) )
return stratify(dataset) return stratify(dataset)
}, }),
}
domain: (state) => (name) => state.domains_details[name], }
highestDomainParentName: (state, getters) => (name) => { type StoreKeys = 'users' | 'permissions' | 'groups' | 'mainDomain' | 'domains'
return getParentDomain(name, getters.orderedDomains, true) type StoreKeysParam =
}, | 'userDetails'
| 'groups'
mainDomain: (state) => state.main_domain, | 'permissions'
| 'mainDomain'
domainsAsChoices: (state) => { | 'domainDetails'
const mainDomain = state.main_domain | 'domains'
return state.domains.map((domain) => { type DataKeys = StoreKeys | StoreKeysParam
return { export type StorePath = `${StoreKeys}` | `${StoreKeysParam}.${string}`
value: domain,
text: domain === mainDomain ? domain + ' ★' : domain, export function useCache<T extends any = any>(
} method: RequestMethod,
}) cachePath: StorePath,
}, ) {
}, const [key, param] = cachePath.split('.') as
| [StoreKeys, undefined]
| [StoreKeysParam, string]
const data = useData()
// FIXME get global cache policy setting
// Add noCache arg? not used
if (!(key in data)) {
throw new Error('Trying to get cache of inexistant data')
}
const d = data[key].value
let content = d as T
if (param) {
if (isObjectLiteral(d) && !Array.isArray(d)) {
content = d[param] as T
} else {
throw new Error('Trying to get param on non object data')
}
}
return {
content,
update: (payload: T) => data.update(method, payload, key, param),
}
}
export function resetCache(keys: DataKeys[]) {
const data = useData()
for (const key of keys) {
if (['domains', 'mainDomain'].includes(key)) {
data[key].value = undefined
} else {
data[key].value = {}
}
}
} }

View file

@ -0,0 +1,44 @@
export type UserItem = {
username: string
fullname: string
mail: string
'mailbox-quota': string
groups: string[]
}
export type UserDetails = {
username: string
fullname: string
mail: string
'mail-aliases': string[]
'mail-forward': string[]
'mailbox-quota': { limit: string; use: string }
}
export type Permission = {
allowed: string[]
corresponding_users: string[]
auth_header: boolean
label: string
show_tile: boolean
protected: boolean
url: string | null
additional_urls: string[]
}
export type Group = {
members: string[]
permissions: string[]
}
export type DomainDetail = {
certificate: {
subject: string
CA_name: string
CA_type: string // enumlike
validity: number
style: string // enumlike
summary: string // enum
ACME_eligible: boolean
}
registrar: string // or null ?
apps: { name: string; id: string; path: string }[]
main: boolean
topest_parent: string | null
}