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,
function update(
method: RequestMethod,
payload: any,
key: DataKeys,
param?: string,
) {
if (key === 'users') {
if (method === 'GET') users.value = payload.users
else if (method === 'POST')
users.value[payload.username] = {
...payload,
'mailbox-quota': 'Pas de quota',
groups: [],
}
} else if (key === 'userDetails' && param) {
if (method === 'GET' || method === 'PUT') {
userDetails.value[param] = payload[param]
} else if (method === 'DELETE') {
delete userDetails.value[param]
delete users.value[param]
}
} else if (key === 'permissions') {
if (method === 'GET') {
permissions.value = payload.permissions
} else if (method === 'PUT' && param) {
permissions.value[param] = payload
}
} else if (key === 'groups') {
if (method === 'GET') {
groups.value = payload.groups
} else if (method === 'POST') {
groups.value[payload.name] = { members: [], permissions: [] }
} else if (method === 'PUT' && param) {
groups.value[param] = payload
} else if (method === 'DELETE' && param) {
delete groups.value[param]
}
} else if (key === 'domains') {
if (method === 'GET') {
domains.value = payload.domains
mainDomain.value = payload.main
} else if (param) {
if (method === 'POST') {
// FIXME api should at least return the domain name on
domains.value?.push(param)
} else if (method === 'PUT') {
mainDomain.value = param
} else if (method === 'DELETE') {
domains.value?.splice(domains.value.indexOf(param), 1)
delete domainDetails.value[param]
}
}
} else if (key === 'domainDetails' && param && method === 'GET') {
domainDetails.value[param] = payload
}
// FIXME rm?
throw new Error(
`couldnt update the cache, key: ${key}, method: ${method}, param: ${param}`,
)
}
return {
users,
userDetails,
groups,
permissions,
mainDomain,
domains,
domainDetails,
update,
}
})
export function useUsersAndGroups(username?: MaybeRefOrGetter<string>) {
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)]
}), }),
mutations: {
SET_DOMAINS(state, [{ domains, main }]) {
state.domains = domains
state.main_domain = main
},
SET_DOMAINS_DETAILS(state, [name, details]) {
state.domains_details[name] = details
},
UPDATE_DOMAINS_DETAILS(state, payload) {
// FIXME use a common function to execute the same code ?
this.commit('SET_DOMAINS_DETAILS', payload)
},
DEL_DOMAINS_DETAILS(state, [name]) {
delete state.domains_details[name]
if (state.domains) {
delete state.domains[name]
} }
}, }
ADD_DOMAINS(state, [{ domain }]) { export function useDomains(domain_?: MaybeRefOrGetter<string>) {
state.domains.push(domain) const { mainDomain, domains, domainDetails } = useData()
},
DEL_DOMAINS(state, [domain]) { const orderedDomains = computed(() => {
state.domains.splice(state.domains.indexOf(domain), 1) if (!domains.value) return
},
// Now applied thru 'SET_DOMAINS'
// 'SET_MAIN_DOMAIN' (state, [response]) {
// state.main_domain = response.current_main_domain
// },
UPDATE_MAIN_DOMAIN(state, [domain]) {
state.main_domain = domain
},
SET_USERS(state, [users]) {
state.users = users || null
},
ADD_USERS(state, [user]) {
if (!state.users) state.users = {}
state.users[user.username] = user
},
SET_USERS_DETAILS(state, [username, userData]) {
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]
}
}
},
UPDATE_USERS_DETAILS(state, payload) {
// FIXME use a common function to execute the same code ?
this.commit('SET_USERS_DETAILS', payload)
},
DEL_USERS_DETAILS(state, [username]) {
delete state.users_details[username]
if (state.users) {
delete state.users[username]
if (Object.keys(state.users).length === 0) {
state.users = null
}
}
},
SET_GROUPS(state, [groups]) {
state.groups = groups
},
ADD_GROUPS(state, [{ name }]) {
if (state.groups !== undefined) {
state.groups[name] = { members: [], permissions: [] }
}
},
UPDATE_GROUPS(state, [data, { groupName }]) {
state.groups[groupName] = data
},
DEL_GROUPS(state, [groupname]) {
delete state.groups[groupname]
},
SET_PERMISSIONS(state, [permissions]) {
state.permissions = permissions
},
UPDATE_PERMISSIONS(state, [_, { groupName, action, permId }]) {
// FIXME hacky way to update the store
const permissions = state.groups[groupName].permissions
if (action === 'add') {
permissions.push(permId)
} else if (action === 'remove') {
const index = permissions.indexOf(permId)
if (index > -1) permissions.splice(index, 1)
}
},
},
actions: {
GET(
{ 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(
{ state, commit },
{ uri, storeKey = uri, data, humanKey, options, ...extraParams },
) {
return api
.fetch('POST', uri, data, humanKey, options)
.then((responseData) => {
// FIXME api/domains returns null
if (responseData === null) responseData = data
responseData = responseData[storeKey]
? responseData[storeKey]
: responseData
commit(
'ADD_' + storeKey.toUpperCase(),
[responseData, extraParams].filter((item) => !isEmptyValue(item)),
)
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( const splittedDomains = Object.fromEntries(
state.domains.map((domain) => { domains.value.map((domain) => {
// Keep the main part of the domain and the extension together // Keep the main part of the domain and the extension together
// eg: this.is.an.example.com -> ['example.com', 'an', 'is', 'this'] // eg: this.is.an.example.com -> ['example.com', 'an', 'is', 'this']
domain = domain.split('.') const domainParts = domain.split('.')
domain.push(domain.pop() + domain.pop()) domainParts.push(domainParts.pop()! + domainParts.pop()!)
return [domain, domain.reverse()] return [domain, domainParts.reverse()]
}), }),
) )
return state.domains.sort( return domains.value.sort((a, b) =>
(a, b) => splittedDomains[a] > splittedDomains[b], splittedDomains[a] > splittedDomains[b] ? 1 : -1,
) )
}, })
domainsTree: (state, getters) => { return {
// This getter will not return any reactive data, make sure to assign its output mainDomain,
// to a component's `data`. domain: computed(() => {
// FIXME manage to store the result in the store to allow reactive data (trigger an if (!domain_) return
// action when state.domain change) return domainDetails.value[toValue(domain_)]
const domains = getters.orderedDomains }),
domains,
domainsAsChoices: computed(() => {
return domains.value?.map((domain) => ({
value: domain,
text: domain === mainDomain.value ? domain + ' ★' : domain,
}))
}),
orderedDomains,
domainsTree: computed(() => {
const domains = orderedDomains.value
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
}