Skip to Content
🚀 APSO is now in public beta. Get started →

CRUD Operations

The APSO SDK provides two ways to perform CRUD operations: the fluent entity API (recommended) and the low-level HTTP methods. This page covers both approaches with complete examples.

Entity API Overview

The entity API is accessed through client.entity('EntityName'), which returns an EntityClient instance. This instance exposes chainable query methods and four semantic CRUD methods:

MethodHTTPDescription
findMany()GET /EntityRetrieve multiple records with optional filtering
findOne()GET /Entity or GET /Entity/:idRetrieve a single record
create(data)POST /EntityCreate a new record
update(data)PUT /Entity/:idUpdate an existing record (requires .where({ id }))
remove()DELETE /Entity/:idDelete a record (requires .where({ id }))

All query builder methods (.where(), .orderBy(), .limit(), etc.) can be chained before any of these terminal methods.

Read Operations

findMany

Retrieve multiple records, optionally with filtering, sorting, and pagination:

// Get all products const products = await client.entity('Products').findMany(); // With filters and sorting const activeProducts = await client.entity('Products') .where({ status: { $eq: 'Active' } }) .orderBy({ created_at: 'DESC' }) .limit(20) .findMany(); // With pagination const page2 = await client.entity('Products') .where({ category: { $eq: 'Electronics' } }) .limit(10) .page(2) .findMany();

The response format depends on your backend configuration. With NestJS CRUD pagination enabled, findMany() returns a paginated response:

interface PaginatedResponse<T> { data: T[]; total: number; page: number; pageCount: number; }

findOne

Retrieve a single record. The behavior depends on how you filter.

By ID — When the only filter is { id: value }, the SDK makes a direct GET /Entity/:id request:

const product = await client.entity('Products') .where({ id: 'prod-123' }) .findOne(); // GET /Products/prod-123

By other fields — When filtering by non-ID fields, the SDK adds limit=1 to the query and returns the first result:

const product = await client.entity('Products') .where({ sku: { $eq: 'WIDGET-001' } }) .findOne(); // GET /Products?filter=sku||$eq||WIDGET-001&limit=1

findOne() handles both array responses (T[]) and paginated responses ({ data: T[] }), returning the first element in either case.

findMany with Joins

Load related entities in a single request using .join():

const orders = await client.entity('Orders') .join(['customer', 'orderItems']) .orderBy({ created_at: 'DESC' }) .limit(10) .findMany(); // GET /Orders?join=customer&join=orderItems&sort=created_at,DESC&limit=10

findMany with Field Selection

Return only specific fields to reduce response size:

const names = await client.entity('Products') .select(['id', 'name', 'price']) .findMany(); // GET /Products?fields=id,name,price

Create

Create a new record by passing a data object to create():

const newProduct = await client.entity('Products').create({ name: 'Widget Pro', price: 49.99, category: 'Electronics', status: 'Active', }); // POST /Products // Body: { name: 'Widget Pro', price: 49.99, category: 'Electronics', status: 'Active' }

The response contains the created record, including any server-generated fields like id, created_at, and updated_at.

console.log(newProduct.id); // 'prod-456' console.log(newProduct.created_at); // '2025-01-15T10:30:00.000Z'

Update

Update an existing record by chaining .where({ id }) before .update():

const updated = await client.entity('Products') .where({ id: 'prod-456' }) .update({ price: 39.99, status: 'OnSale', }); // PUT /Products/prod-456 // Body: { price: 39.99, status: 'OnSale' }

Only the fields you provide in the data object are updated. Other fields remain unchanged.

Important: The .where() filter must include an id field for update(). Calling update() without an ID throws an error:

// This throws: "ID is required for update. Use .where({ id: recordId }) before .update()" await client.entity('Products') .where({ status: { $eq: 'Draft' } }) .update({ status: 'Active' });

Delete

Delete a record by chaining .where({ id }) before .remove():

const result = await client.entity('Products') .where({ id: 'prod-456' }) .remove(); // DELETE /Products/prod-456

Important: Like update(), the .where() filter must include an id field. Calling remove() without an ID throws an error:

// This throws: "ID is required for delete. Use .where({ id: recordId }) before .remove()" await client.entity('Products') .where({ status: { $eq: 'Archived' } }) .remove();

Low-Level HTTP Methods

For full control over the request, use the client’s get(), post(), put(), and delete() methods directly. These methods accept raw resource paths and are useful for custom endpoints or non-standard operations.

GET with Query Parameters

const response = await client.get('/Products', { filter: { status: { $eq: 'Active' }, price: { $lte: 100 }, }, sort: { created_at: 'DESC' }, limit: 10, page: 1, cache: true, });

The get() method accepts optional caching parameters:

// Enable caching with a 120-second TTL const cached = await client.get('/Products', { limit: 10 }, true, 120);

POST

const created = await client.post('/Products', { name: 'Widget', price: 29.99, });

PUT

const updated = await client.put('/Products/prod-123', { price: 24.99, });

DELETE

await client.delete('/Products/prod-123');

Caching

The SDK includes an in-memory cache for GET requests. Caching is available through both the entity API and the low-level API.

Entity API Caching

const products = await client.entity('Products') .where({ status: { $eq: 'Active' } }) .cache(true, 120) // Cache for 120 seconds .findMany();

The .cache() method accepts two parameters:

  • useCache (boolean, default true) — whether to enable caching
  • duration (number, default 60) — cache TTL in seconds

Low-Level API Caching

const products = await client.get('/Products', { limit: 10 }, true, 120); // ^ ^ // useCache duration (seconds)

Clear Cache

Clear all cached responses:

client.clearCache();

Note: The cache is in-memory and per-client-instance. It does not persist across process restarts and is not shared between different client instances.

Error Handling

The SDK throws standard JavaScript Error objects when requests fail. The error message includes the HTTP status code:

try { await client.entity('Products') .where({ id: 'nonexistent' }) .findOne(); } catch (error) { console.error(error.message); // "HTTP error! status: 404" }

Common Error Patterns

try { const result = await client.entity('Products').create({ name: 'Widget', }); } catch (error) { if (error.message.includes('400')) { console.error('Validation error -- check required fields'); } else if (error.message.includes('401')) { console.error('Authentication failed -- check your API key'); } else if (error.message.includes('409')) { console.error('Conflict -- a record with this value already exists'); } else if (error.message.includes('500')) { console.error('Server error -- try again later'); } }

Handling ID Requirement Errors

The update() and remove() methods throw synchronously if no ID filter is set:

try { await client.entity('Products') .where({ status: { $eq: 'Draft' } }) .update({ status: 'Active' }); } catch (error) { console.error(error.message); // "ID is required for update. Use .where({ id: recordId }) before .update()" }

Legacy Methods

The entity API includes deprecated HTTP method aliases for backward compatibility. These methods still work but you should use the semantic methods instead.

DeprecatedReplacement
.get().findMany()
.post(data).create(data)
.put(data).where({ id }).update(data)
.delete().where({ id }).remove()
// Deprecated -- still works but not recommended const data = await client.entity('Products').where({ status: 'active' }).get(); // Preferred const data = await client.entity('Products').where({ status: 'active' }).findMany();

Testing with axios-mock-adapter

When testing code that uses the SDK, configure the client to use axios as the transport and mock responses with axios-mock-adapter.

Setup

npm install --save-dev axios-mock-adapter @types/axios-mock-adapter

Complete Test Example

import { ApsoClientFactory } from '@apso/sdk'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; describe('Product Service', () => { const config = { baseURL: 'https://api.example.com', apiKey: 'test-api-key', client: 'axios' as const, }; const client = ApsoClientFactory.getClient(config); const mock = new MockAdapter(axios); afterEach(() => { mock.reset(); }); test('findMany with filters', async () => { mock.onGet('/Products').reply(200, { data: [{ id: 1, name: 'Widget', status: 'Active' }], total: 1, page: 1, pageCount: 1, }); const result = await client.entity('Products') .where({ status: { $eq: 'Active' } }) .limit(20) .findMany(); expect(result.data).toHaveLength(1); expect(result.data[0].name).toBe('Widget'); }); test('findOne by ID', async () => { mock.onGet('/Products/123').reply(200, { id: 123, name: 'Widget', }); const product = await client.entity('Products') .where({ id: '123' }) .findOne(); expect(product.name).toBe('Widget'); }); test('create a record', async () => { const newProduct = { name: 'New Widget', price: 29.99 }; mock.onPost('/Products', newProduct).reply(201, { id: 456, ...newProduct, }); const created = await client.entity('Products').create(newProduct); expect(created.id).toBe(456); expect(created.name).toBe('New Widget'); }); test('update a record', async () => { const updateData = { name: 'Updated Widget' }; mock.onPut('/Products/123', updateData).reply(200, { id: 123, ...updateData, }); const updated = await client.entity('Products') .where({ id: '123' }) .update(updateData); expect(updated.name).toBe('Updated Widget'); }); test('update without ID throws error', async () => { await expect( client.entity('Products') .where({ status: { $eq: 'Active' } }) .update({ name: 'Test' }) ).rejects.toThrow('ID is required for update'); }); test('remove a record', async () => { mock.onDelete('/Products/123').reply(200, { success: true }); const result = await client.entity('Products') .where({ id: '123' }) .remove(); expect(result.success).toBe(true); }); test('remove without ID throws error', async () => { await expect( client.entity('Products') .where({ status: { $eq: 'Inactive' } }) .remove() ).rejects.toThrow('ID is required for delete'); }); test('GET with join and orderBy', async () => { mock.onGet('/Orders').reply(200, { data: [{ id: 1 }] }); const data = await client.entity('Orders') .join(['customer']) .orderBy({ created_at: 'ASC' }) .findMany(); expect(data.data).toHaveLength(1); }); test('GET with offset and page', async () => { mock.onGet('/Products').reply(200, { data: [] }); const data = await client.entity('Products') .offset(5) .page(2) .findMany(); expect(data).toBeDefined(); }); test('GET with select', async () => { mock.onGet('/Products').reply(200, { data: [] }); const data = await client.entity('Products') .select(['name', 'price']) .findMany(); expect(data).toBeDefined(); }); });

Testing Tips

  • Always set client: 'axios' in test configurations so that axios-mock-adapter can intercept requests
  • Call mock.reset() in afterEach to clear mock state between tests
  • Use ApsoClientFactory.clearClients() in beforeAll or beforeEach if you need a fresh client instance
  • The mock adapter matches on method and URL path; query parameters in the URL may need exact matching depending on your mock setup
  • Query Building — Learn about filtering, sorting, pagination, and more
  • Configuration — Set up authentication, retry, and transport options
Last updated on