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:
| Method | HTTP | Description |
|---|---|---|
findMany() | GET /Entity | Retrieve multiple records with optional filtering |
findOne() | GET /Entity or GET /Entity/:id | Retrieve a single record |
create(data) | POST /Entity | Create a new record |
update(data) | PUT /Entity/:id | Update an existing record (requires .where({ id })) |
remove() | DELETE /Entity/:id | Delete 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-123By 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=1findOne() 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=10findMany 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,priceCreate
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 anidfield forupdate(). Callingupdate()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-456Important: Like
update(), the.where()filter must include anidfield. Callingremove()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, defaulttrue) — whether to enable cachingduration(number, default60) — 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.
| Deprecated | Replacement |
|---|---|
.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-adapterComplete 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 thataxios-mock-adaptercan intercept requests - Call
mock.reset()inafterEachto clear mock state between tests - Use
ApsoClientFactory.clearClients()inbeforeAllorbeforeEachif 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
Related
- Query Building — Learn about filtering, sorting, pagination, and more
- Configuration — Set up authentication, retry, and transport options