14 KiB
Data Layer Design and Implementation
This document describes the data-layer used in ngcp-csc-ui. It explains the responsibilities, contracts, patterns. Use this doc when adding new APIs, handling pagination, caching, or wiring data into Vuex.
Key files
src/api/common.js— main HTTP client and helpers (axios instancehttpApi,getList,get,post,put,patch,del,apiGet,apiPost).src/api/utils.js— minor helpers such asgetJsonBody.src/api/*— domain wrappers that callsrc/api/common.jshelpers (e.g.,src/api/communication.js,src/api/fax.js,src/api/ngcp-call.jsfor SIP control).src/store/— Vuex modules that consume API functions and convert responses to application state.src/storage.jsandsrc/auth.js— storage and JWT helpers used bysrc/api/common.js.
API contract and conventions
- Function inputs: option objects use these common fields:
path,resource,resourceId,params,body,headers,blob,responseType, andconfig. - For convenience, providing
resource/resourceIdautomatically maps the path toapi/<resource>/orapi/<resource>/<resourceId>. - Functions return either:
- A parsed entity (from JSON body),
- A generated id (when server responds with
Locationheader but no body), - A URL object for blobs (when
blob === true).
- Error semantics:
ApiResponseErroris thrown when server returns structured{ code, message }. Otherwise axios/network errors are rethrown.
Error Handling
Error handling is centralized for HTTP in common.js (axios instance + handleResponseError + ApiResponseError), propagated into Vuex modules (actions commit failed mutations with error.message), and SIP/WebRTC errors surface via callEvent events handled in ngcp-call.js which convert SIP events into store actions/mutations.
Responsibilities by layer
HTTP / API:
common.js — http client (httpApi), ApiResponseError class, initAPI, request interceptor, handleResponseError, and API helpers (get, post, getList, apiGet, apiPost, cancel helpers).
utils.js — getJsonBody used when parsing bodies.
domain wrappers: src/api/*.js (e.g., src/api/communication.js::createFax) — call the above helpers and rely on errors thrown/propagated by common.js.
Behaviour
-
Request setup:
initAPI({ baseURL })setshttpApi.defaults.baseURL. A request interceptor addsAuthorizationheader whenhasJwt()is true (callsgetJwt()inauth.js). -
Error transformation (central): The place for mapping server responses to application errors is
handleResponseError(err)incommon.js. What does it do? Extract code and message from err.response.data (if present).SCENARIO 1 - Error is present:
- Special cases:
code === 403 && message === 'Invalid license' → translate to user friendly i18n message.
code === 403 && message === 'Password expired' → set i18n message and perform this.$router?.push({ path: PATH_CHANGE_PASSWORD }) (note: uses optional this which depends on calling scope).
- Otherwise throws new ApiResponseError(code, message) (class includes code, status, message).
SCENARIO 2 - Error is not present: rethrow original err.
Many domain API helpers call handleResponseError(err) when catching axios errors; some API wrappers return or rethrow the result so callers (store actions) get the transformed error.
- axios cancellation detection
apiCreateCancelObject() produces a CancelToken source; apiIsCanceledRequest(exception) uses axios.isCancel(exception). Domain/store code can use that to ignore canceled requests.
- Return shapes on success vs error
Success: parsed JSON (via getJsonBody and normalizeEntity) or blob/url, or identifier from Location header.
Error: either
ApiResponseError(structured) or axios/network error.
Vuex / UI:
src/store/* modules — follow a request/mutation pattern; on error they commit *Failed and often pass err.message to store state/getters (example: fax.js).
Pattern:
- commit
*Requesting - call API helper (e.g., createFax)
- on error: commit
*Failedpassingerr.messageoften used by getter to provide i18n fallback text
Example: fax.js (excerpt)
- action
createFaxcommitscreateFaxRequesting(), - then calls
createFax(...) - On catch, commits
createFaxFailed(err.message). - Getter
createFaxErrorreturns eitherstate.createFaxErroror fallback i18n string.
SIP:
ngcp-call.js — JsSIP UA, emits events on error/failed/ended/ice errors via callEvent.
ngcp-call.js — listens to callEvent and maps events to store commits/dispatches (e.g., callFailed() maps some events to store.dispatch('call/end', { cause })).
Pattern: SIP errors are mapped to store actions which update UI state (call ended/failed).
ngcp-call.js uses JsSIP and emits events via callEvent
ngcp-call.js sets up high-level handlers like:
callEvent.on('connected', ...) → store.commit('call/enableCall')
callEvent.on('disconnected', ({ error, code }) => { store.commit('call/disableCall', { error: errorMessage }) })
callEvent.on('outgoingFailed', callFailed) and callFailed extracts cause and does store.dispatch('call/end', { cause })
Special behavior & notable code decisions
- Password expiry:
handleResponseErrorcode inspectscode === 403andmessage === 'Password expired'and redirects to change-password. This is done insidehandleResponseErrorwiththis.$router?.push(...). That coupling is somewhat fragile becausehandleResponseErroris a plain function and this depends on invocation context, maybe we should refactor to use a response interceptor instead. - Mapping of server error strings ('Invalid license') to i18n-friendly messages occurs inside
handleResponseError. - Many store modules expect
err.messageto be a user-friendly string (they often pass it directly tocreateXFailedmutations), so howhandleResponseErrorsets message is important.
Storage & Auth:
auth.js, storage.js — used to attach Authorization header; errors from auth or expired password are handled in handleResponseError (see redirect behavior).
Patterns for Vuex modules
-
Single responsibility: modules should only know how to transform API results into state, and orchestrate actions/mutations for requests.
-
Action pattern:
- commit a "requesting" mutation (sets RequestState.requesting)
- call domain API function
- on success, commit a "succeeded" mutation with normalized data
- on failure, commit a "failed" mutation and surface user-friendly message from store getters
-
Example (based on
src/store/fax.js/src/store/*):actions.createFaxbuilds options (incl. subscriber id), commitscreateFaxRequesting, callscreateFaxand commits success/failure mutations.
Pagination and client-side lists
- Use
getList({ resource: 'resourceName', page, rows, headers, params, all }). - For
all === true,getListwill first fetch default rows, checktotal_countand re-request with a largerowsvalue if necessary. - Use the returned
{ items, lastPage, totalCount }shape.
Implementation guidelines (how to add a new endpoint)
-
If the endpoint is a standard REST resource (GET/POST/PUT/DELETE):
- Add a domain API wrapper in
src/api/your-resource.jswith functions that callget/post/put/del. - Use
resourceandresourceIdoptions whenever possible to benefit from path mapping.
- Add a domain API wrapper in
-
If the endpoint requires special content-type (e.g., multipart or a blob):
- Build
FormDataor setresponseType/blobappropriately and callpostorapiGetdirectly.
- Build
-
Add Vuex module changes:
- Add a new module under
src/store/or extend an existing one. - Follow the request/action/mutation pattern, and use store getters to return user-facing messages (i18n keys can be used here).
- Add a new module under
Cancellation example
import { apiCreateCancelObject, apiIsCanceledRequest } from 'src/api/common'
const canceler = apiCreateCancelObject()
httpApi.get('/api/resource', { cancelToken: canceler.token })
// To cancel:
canceler.cancel('user navigation')
// In error handlers:
if (apiIsCanceledRequest(err)) {
// ignore or handle graceful cancellation
}
Caching and invalidation
- The codebase currently does not implement a client-side cache layer (beyond Vuex state). For lists, the store is the cache.
Composables for State Access
Starting with the migration to Vue 3 Composition API, the application introduces composables as a layer between components and Vuex store:
-
Purpose: Composables provide reactive access to store state and actions without direct store coupling in components.
-
Pattern (based on
src/composables/useUser.js):- Import store and computed from Vue
- Define reactive refs using
computed(() => store.state/getters.xxx) - Wrap store actions in simple functions
- Return an object with all reactive properties and methods
-
Example:
import { computed } from 'vue' import { store } from 'src/boot/store' export function useUser() { const isAdmin = computed(() => store.getters['user/isAdmin']) const login = (credentials) => store.dispatch('user/login', credentials) return { isAdmin, login } }
Store Access Patterns
Options API (Legacy)
import { mapState, mapGetters, mapActions } from 'vuex'
export default {
computed: {
...mapState('user', ['subscriber']),
...mapGetters('user', ['isLogged', 'isAdmin'])
},
methods: {
...mapActions('user', ['login', 'logout'])
}
}
Composition API with <script setup> (Recommended)
For components using <script setup>, use store composables:
<script setup>
import { useUser } from 'src/composables/useUser'
const {
subscriber, // reactive state (computed ref)
isLogged, // reactive getter (computed ref)
isAdmin, // reactive getter (computed ref)
login, // action function
logout // action function
} = useUser()
// Use them directly
const handleLogin = async () => {
await login({ username: 'test', password: 'pass' })
if (isLogged.value) {
console.log('Welcome', subscriber.value.username)
}
}
</script>
<template>
<div>
<button @click="handleLogin">Login</button>
<div v-if="isLogged">Welcome {{ subscriber.username }}</div>
</div>
</template>
Note: Composables return computed refs, so access values with .value in script, but not in template.
Direct Store Access (Services/Utilities)
For non-component files:
import { store } from 'src/boot/store'
export function someService() {
const user = store.state.user.subscriber
const isLogged = store.getters['user/isLogged']
store.dispatch('user/login', credentials)
}
Generic Store Helpers (useStore)
For modules without dedicated composables, use generic helpers:
<script setup>
import { useState, useGetters, useActions } from 'src/composables/useStore'
// Map state
const { myData } = useState('myModule', ['myData'])
// Map getters
const { isValid } = useGetters('myModule', ['isValid'])
// Map actions
const { loadData, saveData } = useActions('myModule', ['loadData', 'saveData'])
const handleLoad = async () => {
await loadData({ id: 1 })
if (isValid.value) {
console.log('Data:', myData.value)
}
}
</script>
Store Helpers
Request State Management
Located in src/store/common.js:
RequestState— Standard state values (initiated, requesting, succeeded, failed)createRequestState()— Creates standard request state objectcreateRequestMutations()— Generates standard mutations for request lifecycleisRequesting(),isSucceeded(),isFailed()— Helper functions
Example:
import { RequestState, createRequestMutations } from './common'
const state = {
user: null,
loadUserState: RequestState.initiated,
loadUserError: null
}
const mutations = {
...createRequestMutations('loadUser', 'user')
}
API Action Wrapper
Located in src/store/apiHelper.js:
withApiCall()— Wraps API calls with automatic mutation handlingcreateLoadingAction()— Generates action with loading/error states
Example:
import { createLoadingAction } from './apiHelper'
import { getUser } from 'src/api/user'
const actions = {
// Automatically handles requesting/succeeded/failed mutations
loadUser: createLoadingAction('loadUser', getUser, {
showError: true,
errorMessage: 'Failed to load user'
})
}
Using Store Data in <script setup> Components
Complete Example: User Profile Component
<script setup>
import { ref, computed } from 'vue'
import { useUser } from 'src/composables/useUser'
import { useActions } from 'src/composables/useStore'
// Get user state and actions
const { subscriber, isLogged, isAdmin } = useUser()
// Get profile actions
const { loadProfile, updateProfile } = useActions('profile', [
'loadProfile',
'updateProfile'
])
// Local state
const editing = ref(false)
const formData = ref({})
// Computed values
const displayName = computed(() =>
subscriber.value ? `${subscriber.value.firstname} ${subscriber.value.lastname}` : ''
)
// Methods
const startEdit = () => {
formData.value = { ...subscriber.value }
editing.value = true
}
const saveChanges = async () => {
await updateProfile(formData.value)
editing.value = false
}
// Load data on mount
await loadProfile()
</script>
<template>
<div v-if="isLogged">
<h1>{{ displayName }}</h1>
<div v-if="isAdmin" class="admin-badge">Admin</div>
<button v-if="!editing" @click="startEdit">Edit</button>
<button v-else @click="saveChanges">Save</button>
</div>
</template>
Handling Errors in <script setup>
<script setup>
import { ref } from 'vue'
import { useState, useActions } from 'src/composables/useStore'
const { loadUserError } = useState('user', ['loadUserError'])
const { loadUser } = useActions('user', ['loadUser'])
const loading = ref(false)
const handleLoad = async () => {
try {
loading.value = true
await loadUser()
} catch (err) {
console.error('Load failed:', err)
// Error already in store via loadUserError
} finally {
loading.value = false
}
}
</script>
<template>
<div>
<button @click="handleLoad" :disabled="loading">Load User</button>
<div v-if="loadUserError" class="error">{{ loadUserError }}</div>
</div>
</template>