ESProfiler Handbook
Platform

useAPI

Guide to defining API endpoints, Data Models, Query Models, and using the reactive useAPI composable in the ES-Profiler platform frontend.

Overview

The useAPI composable is the primary mechanism for executing HTTP requests and managing API state in the ES-Profiler platform frontend (platform-frontend).

It replaces the legacy BaseApi utility class with a fully reactive, TypeScript-safe Vue composable.

useAPI integrates directly with Vue 3 reactivity (shallowRef, reactive), Axios, reactive Data and Query models (BaseDataModel, BaseListModel, BaseQuery), and authentication handlers.

Key Features

  • Automatic Type Safety: TypeScript autocomplete and type checking for registered endpoints via Vite type generation.
  • Reactive State: Exposes data, query, loading, loaded, success, and error state.
  • Dynamic Path Interpolation: Automatically substitutes URL path parameters like :productId or :findingId.
  • Request Deduplication: Prevents duplicate GET requests with identical destination parameters.
  • Auto Abort: Automatically cancels in-flight HTTP requests when re-executed on the same composable instance.
  • Built-in Authorization & Error Handling: Manages silent token authentication, automatic 401 signouts, and interactive 403 Permission Denied modal dialogs.

Defining API Endpoints (defineAPI)

API endpoint definitions are structured modularly in src/api/**/*.ts within the platform-frontend repository.

Each module exports a default APIConfig object defining the baseURL and endpoint registry entries.

Namespace Naming Convention

Endpoint namespaces are automatically derived from the file path relative to src/api/:

File PathGenerated NamespaceEndpoint KeyFull Endpoint String
src/api/feature.tsfeatureinternal.create'feature.internal.create'
src/api/meta.tsmetatags'meta.tags'
src/api/insights/findings.tsinsights.findingsdelete'insights.findings.delete'
src/api/hub/interviews.tshub.interviewslist'hub.interviews.list'

Example API Module

src/api/feature.ts
import { useConfig } from '@/composables/useConfig.ts'
import type { APIConfig } from '@/types/api'

const config: APIConfig = {
  baseURL: useConfig('api.esp'),
  endpoints: {
    'internal.create': {
      path: '/v1/inventory/internal/products/:productId/features',
      method: 'post'
    },
    'internal.update': {
      path: '/v1/inventory/internal/products/:productId/features/:featureId',
      method: 'patch'
    },
    'internal.delete': {
      path: '/v1/inventory/internal/products/features/:featureId',
      method: 'delete'
    }
  }
}

export default config

Configuration Options

OptionTypeDefaultDescription
pathstring(Required)The endpoint URL template. Can include path parameters prefixed with a colon (e.g. :id).
methodstring'get'HTTP method ('get', 'post', 'put', 'patch', 'delete').
modelClass | Object | nullundefinedData model class (BaseDataModel subclass, BaseListModel, array, plain object, or null).
queryClass | ObjectundefinedQuery model class (BaseQuery subclass or plain object) for managing search and filter parameters.
multiplebooleanAuto-detectedSet to true if response represents an array or collection. Automatically inferred if model is an Array or BaseListModel.
authenticationbooleantrueWhen true, automatically waits for user authentication and injects the Authorization Bearer token header.

Advanced APIConfig: model and query Patterns

When defining an API module, model and query options can be declared at the module root (APIConfig level) or overridden per endpoint.

1. Module-Level Defaults vs. Endpoint Overrides

  • Module-Level Default: Declaring model or query at the root of APIConfig applies that configuration as a fallback for all endpoints in the module.
  • Endpoint-Level Override: An endpoint can specify its own model or query to override the module-wide default.

2. Model Configuration Options

  • BaseDataModel Subclass (model: TaskHubInterviewModel): useAPI instantiates and calls setup() on the class.
  • Array / BaseListModel (model: [TaskHubInterviewModel] or ProductListModel): Automatically flags multiple = true and wraps items in a BaseListModel.
  • Plain Object Literal (model: { url: '' }): For lightweight key-value payloads that do not require full model features.
  • null (model: null): Explicitly specifies that an endpoint (e.g. DELETE or bodyless PATCH) does not send or expect a model body.

3. Query Configuration Options

  • BaseQuery Subclass (query: TaskHubInterviewQuery): Automatically instantiated and initialized by useAPI.
  • Plain Object Literal (query: { term: '', filter: 'active' }): Automatically wrapped by useAPI into a WrappedQuery instance.

Real-World API Module Example

src/api/hub/interviews.ts
import { useConfig } from '@/composables/useConfig.ts'
import TaskHubInterviewQuery from '@/models/Api/Query/TaskHubInterviewQuery.ts'
import { TaskHubInterviewModel } from '@/models/Data/TaskHub/TaskHubInterviewModel.ts'
import { TaskHubInterviewStatsModel } from '@/models/Data/TaskHub/TaskHubInterviewStatsModel.ts'
import type { APIConfig } from '@/types/api'

const config: APIConfig = {
  baseURL: useConfig('api.esp'),

  // Module-wide default model: inherited by 'list' and 'show' below
  model: TaskHubInterviewModel,

  endpoints: {
    // 1. Uses module-level TaskHubInterviewModel + query class + multiple collection flag
    list: {
      path: '/v1/user-tasks',
      multiple: true,
      query: TaskHubInterviewQuery
    },

    // 2. Automatically inherits module-level TaskHubInterviewModel
    show: {
      path: '/v1/user-tasks/:interviewId'
    },

    // 3. Overrides model with a lightweight plain object literal
    link: {
      path: '/v1/user-tasks/:interviewId/url',
      model: { url: '' }
    },

    // 4. Overrides model with a specific BaseDataModel class + custom query model
    stats: {
      path: '/v1/user-tasks/stats',
      model: TaskHubInterviewStatsModel,
      query: TaskHubInterviewQuery
    },

    // 5. Overrides model with null for actions without a body payload
    'group.delete': {
      path: '/v1/user-tasks/groups/:groupId',
      method: 'delete',
      model: null
    },

    // 6. Overrides model with null for action patch
    'group.reminder': {
      path: '/v1/user-tasks/groups/:groupId/emails',
      method: 'patch',
      model: null
    }
  }
}

export default config

Automatic Type Safety (vite-plugin-api-types)

The platform uses a custom Vite plugin (src/plugins/api/vite.mts) to auto-generate TypeScript definitions for registered endpoints.

During development (vite dev) and build (vite build), the plugin watches src/api/**/*.ts and updates api-registry.d.ts in the project root. This populates the global APIRegistry interface, giving IDE autocomplete and type errors for invalid API keys.
// Autocomplete suggests valid endpoints such as 'meta.tags', 'feature.internal.create', etc.
const tags = useAPI('meta.tags')

Data Models (BaseDataModel & BaseListModel)

Data Models define the structure, default values, and mutation behaviors of entities returned by or sent to backend APIs. useAPI automatically instantiates and binds response resource objects to these data models.

Subclassing BaseDataModel

To create a single-entity data model, extend BaseDataModel:

src/models/Data/Inventory/ProductModel.ts
import BaseDataModel from '@/models/Data/Utility/BaseDataModel'

export default class ProductModel extends BaseDataModel {
  id: string = ''
  name: string = ''
  description: string = ''
  active: boolean = true

  constructor() {
    super()
  }
}

Automatic Nested BaseListModel Instantiation

When defining nested model relationships in a BaseDataModel, you can declare an array attribute initialized with the target model class or instance (e.g. products = [ProductModel]).

During setup(), BaseDataModel automatically converts array properties containing a BaseDataModel class constructor or instance into an auto-instantiated BaseListModel:

src/models/Data/Inventory/VendorModel.ts
import BaseDataModel from '@/models/Data/Utility/BaseDataModel'
import ProductModel from '@/models/Data/Inventory/ProductModel'

export default class VendorModel extends BaseDataModel {
  id: string = ''
  name: string = ''

  // Declaring [ProductModel] automatically converts 'products' into a BaseListModel<ProductModel>
  products = [ProductModel]

  constructor() {
    super()
  }
}
When setup() or assign() runs, VendorModel.products is automatically transformed into new BaseListModel(ProductModel). Any incoming JSON data for products will then be instantiated and cast as ProductModel items within the BaseListModel.

Model Lifecycle & State Tracking

BaseDataModel tracks internal snapshots of model state to support dirty-checking, rollback, and selective property serialization:

  • __original: Snapshot of the object state after setup() is first called.
  • __previous: Snapshot of the last committed or assigned state.
  • isDirty: Getter returning true if current field values differ from __previous.
  • postable: Getter returning only enumerable properties suitable for sending in POST/PUT/PATCH request bodies.

Data Model Methods

MethodSignatureDescription
setup(custom?, warn?)(custom?: Partial<this>, warn?: boolean) => thisInitializes nested list models, applies custom overrides, and records initial __original / __previous snapshots.
assign(data, cast?, update?)(data?: Partial<this>, cast?: boolean, update?: boolean) => thisMerges new property values into the model instance, optionally casting types and recording __previous.
update(data, cast?)(data?: Partial<this>, cast?: boolean) => thisAlias for assign with update = true enabled.
commited()() => thisCommits the current property state as the new __previous reference, clearing the isDirty flag after a successful save.
undo()() => thisReverts modified property values back to the __previous snapshot.
reset(data?, hard?)(data?: Partial<this>, hard?: boolean) => thisReverts the model to __original snapshot values, optionally overriding fields. Performs a commited() if hard = true.
const product = new ProductModel().setup({ name: 'Draft Product' })

product.name = 'New Name'
console.log(product.isDirty) // true

product.undo()
console.log(product.name) // 'Draft Product'

Handling Collections (BaseListModel)

When an endpoint returns a list of items or has multiple: true, useAPI wraps the model in a BaseListModel.

src/models/Data/Inventory/ProductListModel.ts
import BaseListModel from '@/models/Data/Utility/BaseListModel'
import ProductModel from '@/models/Data/Inventory/ProductModel'

export default class ProductListModel extends BaseListModel<typeof ProductModel> {
  constructor() {
    super(ProductModel)
  }
}

Key BaseListModel Properties & Methods

  • list: Array of strongly typed BaseDataModel instances.
  • total: Number of total items available on the backend (populated from paginated API responses).
  • pages: Total page count returned by paginated API responses.
  • assign(items): Instantiates and appends or replaces items in list.
  • withCache(identifier): Enables result caching using a specific key (e.g. 'id').
  • clear(): Empties the list array and resets total to 0.
  • reset(items): Re-assigns items and resets pagination metadata.

Query Models (BaseQuery & PaginationQuery)

Query Models manage request search filters, sorting options, pagination parameters, and synchronization with Vue Router URL query strings.

Subclassing BaseQuery

Extend BaseQuery to construct search filter models for non-paginated endpoints:

src/models/Api/Query/FilterQuery.ts
import BaseQuery from '@/models/Api/Utility/BaseQuery'

export default class FilterQuery extends BaseQuery {
  category: string = ''
  status: string = 'active'

  constructor() {
    super('filter') // 'filter' is the query scope prefix
  }
}

Query Scoping & Router Synchronization

Query models support scoping (__scope) to avoid query parameter collisions when multiple components share URL query parameters:

  • Scoped URL Output: When a scope is supplied (e.g. tags), parameters are rendered with a delimiter in the URL: ?tags~searchTerm=security.
  • routable: Getter returning sanitized query parameter pairs ready for Vue Router.
  • go(router, absolute?): Updates Vue Router URL query string with current query model properties.
  • parse(payload, props?, scope?): Deserializes route query strings or parameter objects directly into model properties.

Deferred State (Draft Filters)

Query models provide a deferred state (deferred) so users can adjust filter controls in a modal or drawer without immediately triggering network requests:

const query = new TagsQuery()

// User edits draft state in form
query.deferred.searchTerm = 'compliance'

// Apply draft changes to active query state
query.commit()

// Or cancel draft changes
query.cancel()

Query State Control Methods

MethodDescription
commit()Copies values from deferred draft state into active query fields and updates filter count.
cancel()Reverts deferred draft state back to active query parameters.
undo()Reverts deferred draft state to the __previous query state snapshot.
reset()Reverts deferred and active query state to initial __original default values.

Paginated Queries (PaginationQuery)

For endpoints supporting page numbers, page sizes, and sorting, extend PaginationQuery:

src/models/Api/Query/TagsQuery.ts
import PaginationQuery from '@/models/Api/Query/PaginationQuery'

export default class TagsQuery extends PaginationQuery {
  type: string = ''
  searchTerm: string = ''

  constructor() {
    super('tags')
  }
}

Built-in PaginationQuery Parameters

  • page: 1-indexed page number (automatically converted to 0-indexed for backend API requests in configuration).
  • size: Page size limit (defaults to 20).
  • sortBy: Array of sort objects: [{ key: 'name', order: 'asc' }]. Automatically serialized to sort: ["name~A"].
  • groupBy: Array of grouping items.
  • search: General search string.
  • configuration: Cleansed object passed directly to Axios request parameters by useAPI.

Using useAPI in Vue Components

1. Destructuring State (Standard Pattern)

Best for components that destructure reactive state and trigger execution explicitly or in lifecycle hooks:

components/tags-filter.vue
<script setup lang="ts">
import { onMounted } from 'vue'
import { useAPI } from '@/composables/useAPI'

const { data, query, execute, loading } = useAPI('meta.tags')

onMounted(() => {
  execute()
})
</script>

<template>
  <v-select
    v-model="query.tag"
    :items="data"
    :loading="loading"
    label="Filter by Tag"
  />
</template>

2. Reactive Wrapper Pattern

Best when passing the entire API instance into templates or computed properties:

components/insights-list.vue
<script setup lang="ts">
import { reactive, onMounted } from 'vue'
import { useAPI } from '@/composables/useAPI'

const signals = reactive(useAPI('insights.signals.list'))

onMounted(() => {
  signals.execute()
})
</script>

<template>
  <v-progress-linear v-if="signals.loading" indeterminate />
  <div v-else-if="signals.loaded">
    <div v-for="item in signals.data" :key="item.id">
      {{ item.title }}
    </div>
  </div>
</template>

3. Per-Instance Customization (Call-Site options)

When calling useAPI('namespace', options), you can pass per-instance options to override or customize the endpoint's default model or query configuration at the call site:

const api = useAPI('example', {
  model: { key: 'value' },
  query: { search: 'term' }
})

This is particularly useful when a component requires a specific initial payload shape, custom query filters, or isolated query scoping.

A. Overriding or Providing Initial model Payload

You can pass a static BaseDataModel class constructor, a custom object literal, or a pre-configured model instance:

// 1. Passing a static BaseDataModel class constructor (auto-instantiated and setup by useAPI)
const productApi = useAPI('product.internal.update', {
  model: ProductModel
})

// 2. Passing a custom object literal as the initial model payload
const featureCreate = useAPI('feature.internal.create', {
  model: { name: 'New Feature', description: '' }
})

// 3. Passing a pre-configured BaseDataModel instance
const customProduct = new ProductModel().setup({ active: true })
const customProductApi = useAPI('product.internal.update', {
  model: customProduct
})

B. Overriding or Providing Custom query Parameters

You can supply per-instance query options as a static BaseQuery class constructor, a plain object literal, or a pre-configured BaseQuery instance:

// 1. Passing a static BaseQuery class constructor (auto-instantiated by useAPI)
const tagsApi = useAPI('meta.tags', {
  query: TagsQuery
})

// 2. Passing a plain query object literal (automatically wrapped in WrappedQuery)
const customTagsApi = useAPI('meta.tags', {
  query: { type: 'system', searchTerm: 'security' }
})

// 3. Passing a pre-configured, scoped BaseQuery instance
const dialogQuery = new TagsQuery('dialogScope')
dialogQuery.searchTerm = 'audit'

const scopedTagsApi = useAPI('meta.tags', {
  query: dialogQuery
})

C. Passing Static Class Constructors for Both model and query

You can simply pass both static class constructors to useAPI. useAPI will automatically instantiate and initialize both the model and query instances:

const productApi = useAPI('product.internal.update', {
  model: ProductModel,
  query: ProductQuery
})

D. Combining Object Literals or Custom Overrides

const directiveApi = useAPI('settings.directive.list', {
  model: { key: 'theme', value: 'dark' },
  query: { category: 'user-preference', limit: 10 }
})
Options supplied to useAPI(namespace, options) take precedence over the endpoint defaults registered in src/api/*.ts.

API Reference

Invoking useAPI(namespace, options?) returns an ApiInstance object containing the following properties and methods:

MemberTypeDescription
dataBaseDataModel | BaseListModel | Object | ArrayReactive data instance populated with the response resource upon request completion.
queryBaseQueryReactive query instance holding URL search parameters.
loadingShallowRef<boolean>true while an HTTP request is in-flight.
loadedShallowRef<boolean>true after at least one successful request has completed.
successShallowRef<boolean>true if the last executed request succeeded.
errorResponseErrorHandlerError handler instance containing active response errors.
execute(options?, force?)(options?: APIExecOptions, force?: boolean) => Promise<any>Executes the HTTP request. Accepts parameter overrides, custom Axios config, or model payloads.
refresh()() => Promise<any>Re-executes the previous request with saved attributes, forcing a fresh network call (bypassing deduplication).
reset(data?, hard?)(data?: any, hard?: boolean) => voidResets loaded to false, resets data model, and clears error state.
undo()() => voidReverts modified values on data model if supported by the model instance.
recallable()() => voidClears the request deduplication memory for this endpoint.

Core Behaviors & Architecture

Path Parameter Interpolation

If an endpoint path includes parameters prefixed with a colon (e.g. :productId), useAPI resolves these values at execution time.

Parameters are resolved from:

  1. The params object passed to execute({ params: { productId: 123 } })
  2. Properties on the data instance matching the parameter key name
const patcher = useAPI('insights.signals.patch')

// Path: /v1/insights/signals/:signalId
await patcher.execute({
  params: { signalId: 'sig_123' },
  model: { title: 'Updated Title' }
})

Request Deduplication

To minimize unnecessary network traffic, useAPI automatically deduplicates identical GET requests sent to the exact same URL and query string.

If a GET request is triggered with a destination URL identical to the immediate previous request, useAPI skips execution and logs a console warning in debug mode.

To bypass deduplication and force a network request, pass force = true to execute() or use refresh():

// Bypasses deduplication
await api.refresh()

// Or explicitly force execution
await api.execute({}, true)

Automatic Request Cancellation

If execute() is called on a useAPI instance while a previous request for the same endpoint is still pending, useAPI automatically aborts the previous in-flight request via AbortController.

Authentication & Error Handling

  1. Token Injection: useAPI calls waitUntilAuthed() and attaches the Authorization: Bearer <token> header automatically before sending authenticated requests.
  2. 401 Unauthorized: Automatically triggers silent sign-out (signoutSilent()) to re-authenticate the user session.
  3. 403 Forbidden: Intercepts HTTP 403 responses and presents an interactive Confirmation.Acknowledge() modal detailing:
    • Target HTTP method and endpoint URL
    • User identity (Email, Client Type, Client ID, Organisation ID)
    • Assigned security policy actions

Code Examples

Form Submission with Payload

components/add-feature-dialog.vue
<script setup lang="ts">
import { ref } from 'vue'
import { useAPI } from '@/composables/useAPI'

const props = defineProps<{ productId: string }>()
const emit = defineEmits(['saved'])

const featureCreate = useAPI('feature.internal.create', {
  model: { name: '', description: '' }
})

const save = async () => {
  try {
    await featureCreate.execute({
      params: { productId: props.productId }
    })
    emit('saved')
  } catch (err) {
    // Error is automatically populated in featureCreate.error
  }
}
</script>

<template>
  <v-card>
    <v-card-text>
      <v-text-field v-model="featureCreate.data.name" label="Feature Name" />
      <v-textarea v-model="featureCreate.data.description" label="Description" />
    </v-card-text>
    <v-card-actions>
      <v-btn
        :loading="featureCreate.loading"
        color="primary"
        @click="save"
      >
        Create Feature
      </v-btn>
    </v-card-actions>
  </v-card>
</template>

Deleting Resources with Parameters

const deletion = useAPI('insights.findings.delete')

const removeFinding = async (findingId: string) => {
  await deletion.execute({
    params: { findingId }
  })
}
Copyright © 2026