From 13dc8de18255579f033a536544f018eb4af07514 Mon Sep 17 00:00:00 2001 From: axolotle Date: Mon, 5 Aug 2024 23:09:35 +0200 Subject: [PATCH] refactor: turn data store to global state with cache handing --- app/src/api/api.ts | 44 ++-- app/src/api/handlers.ts | 8 +- app/src/composables/data.ts | 482 +++++++++++++++--------------------- app/src/types/core/data.ts | 44 ++++ 4 files changed, 268 insertions(+), 310 deletions(-) create mode 100644 app/src/types/core/data.ts diff --git a/app/src/api/api.ts b/app/src/api/api.ts index 38878d5e..ef739fa5 100644 --- a/app/src/api/api.ts +++ b/app/src/api/api.ts @@ -1,3 +1,4 @@ +import { useCache, type StorePath } from '@/composables/data' import { useInfos } from '@/composables/useInfos' import { useRequests, @@ -19,8 +20,7 @@ export type HumanKey = { export type APIQuery = { method?: RequestMethod uri: string - cachePath?: string - cacheParams?: Obj + cachePath?: StorePath data?: Obj humanKey?: string | HumanKey showModal?: boolean @@ -95,11 +95,10 @@ export default { * @returns Promise that resolve the api response data * @throws Throw an `APIError` or subclass depending on server response */ - async fetch({ + async fetch({ uri, method = 'GET', cachePath = undefined, - cacheParams = undefined, data = undefined, humanKey = undefined, showModal = method !== 'GET', @@ -107,6 +106,9 @@ export default { initial = false, asFormData = true, }: APIQuery): Promise { + const cache = cachePath ? useCache(method, cachePath) : undefined + if (method === 'GET' && cache?.content) return cache.content + const { locale } = useSettings() const { startRequest, endRequest } = useRequests() @@ -136,14 +138,18 @@ export default { } const response = await fetch('/yunohost/api/' + uri, options) - const responseData = await getResponseData(response) - endRequest({ request, success: response.ok }) if (!response.ok) { - throw getError(request, response, responseData as string | APIErrorData) + const errorData = await getResponseData(response) + endRequest({ request, success: false }) + throw getError(request, response, errorData) } - return responseData as T + const responseData = await getResponseData(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 * @throws Throw an `APIError` or subclass depending on server response */ - async fetchAll( + async fetchAll( queries: APIQuery[], { showModal = false, initial = false } = {}, - ) { - const results: Array = [] + ): Promise { + const results = [] for (const query of queries) { if (showModal) query.showModal = true if (initial) query.initial = true 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 * @throws Throw an `APIError` or subclass depending on server response */ - get( + get( query: string | Omit, ): Promise { 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 * @throws Throw an `APIError` or subclass depending on server response */ - post( - query: Omit, - ): Promise { + post(query: Omit): Promise { return this.fetch({ ...query, method: 'POST' }) }, @@ -208,9 +212,7 @@ export default { * @returns Promise that resolve the api response data or an error * @throws Throw an `APIError` or subclass depending on server response */ - put( - query: Omit, - ): Promise { + put(query: Omit): Promise { return this.fetch({ ...query, method: 'PUT' }) }, @@ -222,9 +224,7 @@ export default { * @returns Promise that resolve the api response data or an error * @throws Throw an `APIError` or subclass depending on server response */ - delete( - query: Omit, - ): Promise { + delete(query: Omit): Promise { return this.fetch({ ...query, method: 'DELETE' }) }, diff --git a/app/src/api/handlers.ts b/app/src/api/handlers.ts index e4e5c1cd..dfed5419 100644 --- a/app/src/api/handlers.ts +++ b/app/src/api/handlers.ts @@ -20,13 +20,15 @@ import type { APIErrorData } from './api' * @param response - A fetch `Response` object. * @returns Parsed response's json or response's text. */ -export async function getResponseData(response: Response) { +export async function getResponseData( + response: Response, +): Promise { // FIXME the api should always return json as response const responseText = await response.text() try { - return JSON.parse(responseText) as Obj + return JSON.parse(responseText) } catch { - return responseText + return responseText as T } } diff --git a/app/src/composables/data.ts b/app/src/composables/data.ts index e778f72e..2e9e3b29 100644 --- a/app/src/composables/data.ts +++ b/app/src/composables/data.ts @@ -1,312 +1,224 @@ -import api from '@/api' -import { isEmptyValue } from '@/helpers/commons' +import { createGlobalState } from '@vueuse/core' +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 { 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) { - const method = highest ? 'lastIndexOf' : 'indexOf' - 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 +function arrayOrNull(items: T): T | null { + return items.length ? items : null } -export default { - state: () => ({ - main_domain: undefined, - domains: undefined, // Array - domains_details: {}, - users: undefined, // basic user data: Object {username: {data}} - users_details: {}, // precise user data: Object {username: {data}} - groups: undefined, - permissions: undefined, - }), +const useData = createGlobalState(() => { + const users = ref>({}) + const userDetails = ref>({}) + const groups = ref>({}) + const permissions = ref>({}) + const mainDomain = ref() + const domains = ref() + const domainDetails = ref>({}) - 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] + 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] } - }, - - ADD_DOMAINS(state, [{ domain }]) { - state.domains.push(domain) - }, - - DEL_DOMAINS(state, [domain]) { - state.domains.splice(state.domains.indexOf(domain), 1) - }, - - // 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] + } 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 + } - UPDATE_USERS_DETAILS(state, payload) { - // FIXME use a common function to execute the same code ? - this.commit('SET_USERS_DETAILS', payload) - }, + // FIXME rm? + throw new Error( + `couldnt update the cache, key: ${key}, method: ${method}, param: ${param}`, + ) + } - 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 - } - } - }, + return { + users, + userDetails, + groups, + permissions, - SET_GROUPS(state, [groups]) { - state.groups = groups - }, + mainDomain, + domains, + domainDetails, - ADD_GROUPS(state, [{ name }]) { - if (state.groups !== undefined) { - state.groups[name] = { members: [], permissions: [] } - } - }, + update, + } +}) - UPDATE_GROUPS(state, [data, { groupName }]) { - state.groups[groupName] = data - }, +export function useUsersAndGroups(username?: MaybeRefOrGetter) { + 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]) { - delete state.groups[groupname] - }, +export function useDomains(domain_?: MaybeRefOrGetter) { + const { mainDomain, domains, domainDetails } = useData() - SET_PERMISSIONS(state, [permissions]) { - state.permissions = permissions - }, + const orderedDomains = computed(() => { + if (!domains.value) return - 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) - } - }, - }, + const splittedDomains = Object.fromEntries( + domains.value.map((domain) => { + // Keep the main part of the domain and the extension together + // eg: this.is.an.example.com -> ['example.com', 'an', 'is', 'this'] + const domainParts = domain.split('.') + domainParts.push(domainParts.pop()! + domainParts.pop()!) + return [domain, domainParts.reverse()] + }), + ) - 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] - }) - }, + return domains.value.sort((a, b) => + splittedDomains[a] > splittedDomains[b] ? 1 : -1, + ) + }) - 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( - 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 + return { + mainDomain, + domain: computed(() => { + if (!domain_) return + return domainDetails.value[toValue(domain_)] + }), + 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 const dataset = reactive( - domains.map((name) => ({ + domains.map((domain) => ({ // data to build a hierarchy - name, - parent: getParentDomain(name, domains), + name: domain, + parent: domainDetails.value[domain].topest_parent, // utility data that will be used by `RecursiveListGroup` component - to: { name: 'domain-info', params: { name } }, + to: { name: 'domain-info', params: { name: domain } }, opened: true, })), ) return stratify(dataset) - }, - - domain: (state) => (name) => state.domains_details[name], - - highestDomainParentName: (state, getters) => (name) => { - return getParentDomain(name, getters.orderedDomains, true) - }, - - mainDomain: (state) => state.main_domain, - - domainsAsChoices: (state) => { - const mainDomain = state.main_domain - return state.domains.map((domain) => { - return { - value: domain, - text: domain === mainDomain ? domain + ' ★' : domain, - } - }) - }, - }, + }), + } +} + +type StoreKeys = 'users' | 'permissions' | 'groups' | 'mainDomain' | 'domains' +type StoreKeysParam = + | 'userDetails' + | 'groups' + | 'permissions' + | 'mainDomain' + | 'domainDetails' + | 'domains' +type DataKeys = StoreKeys | StoreKeysParam +export type StorePath = `${StoreKeys}` | `${StoreKeysParam}.${string}` + +export function useCache( + 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 = {} + } + } } diff --git a/app/src/types/core/data.ts b/app/src/types/core/data.ts new file mode 100644 index 00000000..4c92c16e --- /dev/null +++ b/app/src/types/core/data.ts @@ -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 +}