HTTP Client
@opra/client is a lightweight, typed HTTP client for consuming OPRA services. It works in any JavaScript environment — browser, Node.js, React, Vue, or plain TypeScript — and has no framework dependencies.
npm install @opra/client
Setup
Create a single client instance and share it across your application:
import { OpraHttpClient } from '@opra/client';
export const client = new OpraHttpClient('https://api.example.com');
Set default headers or query parameters applied to every request:
export const client = new OpraHttpClient('https://api.example.com', {
defaults: {
headers: new Headers({ Authorization: `Bearer ${getToken()}` }),
params: new URLSearchParams({ version: '2' }),
},
});
You can also update defaults after construction — useful when a token is refreshed:
client.defaults.headers.set('Authorization', `Bearer ${newToken}`);
Making requests
// GET
const orders = await client.get<Order[]>('orders').getBody();
// GET with query parameters
const filtered = await client
.get<Order[]>('orders')
.param('filter', 'status = "active"')
.param('limit', 20)
.getBody();
// POST
const created = await client.post<Order>('orders', {
productId: 'p1',
qty: 2,
}).getBody();
// PATCH
await client.patch('orders/123', { status: 'shipped' }).getBody();
// PUT (full replace)
await client.put('orders/123', replacementPayload).getBody();
// DELETE
await client.delete('orders/123').getBody();
Per-request headers
Chain .header() to add headers that apply only to this request without affecting the client defaults:
const report = await client
.get<Report>('reports/monthly')
.header('X-Tenant-Id', tenantId)
.header('Accept-Language', 'tr')
.param('month', '2024-12')
.getBody();
Reading the full response
Use .getResponse() when you need the HTTP status code or response headers in addition to the body:
const res = await client.post<Order>('orders', input).getResponse();
if (res.ok) {
console.log('Created:', res.body);
console.log('Location:', res.headers.get('Location'));
} else {
console.error('Status:', res.status);
}
Observable / RxJS
Every request method returns an HttpRequestObservable — a lazy RxJS Observable that only sends the request when subscribed. This makes it a natural fit for reactive patterns:
import { switchMap } from 'rxjs';
userId$.pipe(
switchMap(id => client.get<User>(`users/${id}`)),
).subscribe(user => console.log(user));
Combine with combineLatest or forkJoin for parallel requests that depend on separate streams:
import { forkJoin } from 'rxjs';
forkJoin({
customer: client.get<Customer>('customers@1'),
orders: client.get<Order[]>('orders').param('customerId', 1),
}).subscribe(({ customer, orders }) => console.log(customer, orders));
Upload / download progress
Progress events are only available through the Observable path:
import { filter } from 'rxjs';
import { HttpObserveType, HttpEventType } from '@opra/client';
client.post('upload', file)
.options({ reportProgress: true })
.observe(HttpObserveType.Events)
.pipe(filter(e => e.type === HttpEventType.UploadProgress))
.subscribe(e => console.log(`${e.loaded} / ${e.total ?? '?'} bytes`));
Batch requests
Use client.bundle() to send multiple requests in a single HTTP round-trip. The client serializes all requests as multipart/mixed parts, posts them to /$bundle, and distributes the sub-responses back to each originating request:
const r1 = client.get<Customer>('customers@1');
const r2 = client.get<Order[]>('orders');
const [res1, res2] = await client.bundle([r1, r2]).getResponses();
console.log(res1.body); // Customer
console.log(res2.body); // Order[]
Requests bound to a bundle do not fire individual network calls — a single POST /$bundle drives them all. After the bundle resolves you can still await each request individually via .getBody() / .getResponse():
// Both resolve immediately from the bundle's cached response:
const customer = await r1.getBody();
const orders = await r2.getBody();
Transactions
Use client.transaction() to wrap the entire batch in a server-side database transaction. The server commits on full success or rolls back on any failure:
const r1 = client.post('orders', { productId: 'p1', qty: 2 });
const r2 = client.patch('inventory/p1', { reserve: 2 });
// Both commit — or both roll back.
await client.transaction([r1, r2]).getResponses();
transaction() is identical to bundle() except it appends ?transaction=true automatically. Transaction support is provided out of the box by @opra/mongodb and @opra/sqb — no server-side handler changes needed.
If one sub-request fails and the bundle is transactional, the entire transaction is rolled back — including parts that already succeeded. Design your batch operations accordingly.
→ Batch Requests & Transactions
Error handling
All 4xx and 5xx responses throw a ClientError with a structured issues array matching the OPRA error format:
import { ClientError } from '@opra/client';
try {
await client.get<Order>('orders/not-found').getBody();
} catch (err) {
if (err instanceof ClientError) {
console.error(err.status); // 404
console.error(err.message); // 'Not found'
console.error(err.issues); // [{ message: 'Not found', code: 'NOT_FOUND' }]
}
}
Network errors and timeouts are also surfaced as ClientError with status: 0.
React
In React, use useState + useEffect for simple cases, or pair with TanStack Query for caching and loading states.
With useEffect
function OrderList() {
const [orders, setOrders] = useState<Order[]>([]);
useEffect(() => {
client.get<Order[]>('orders').getBody().then(setOrders);
}, []);
return <ul>{orders.map(o => <li key={o.id}>{o.name}</li>)}</ul>;
}
With TanStack Query
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
function useOrders() {
return useQuery({
queryKey: ['orders'],
queryFn: () => client.get<Order[]>('orders').getBody(),
});
}
function useCreateOrder() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: OrderInput) =>
client.post<Order>('orders', input).getBody(),
onSuccess: () => qc.invalidateQueries({ queryKey: ['orders'] }),
});
}
Vue
In Vue 3, use ref + onMounted, or integrate with Pinia for shared state.
With Composition API
// composables/useOrders.ts
import { ref, onMounted } from 'vue';
export function useOrders() {
const orders = ref<Order[]>([]);
const loading = ref(false);
const error = ref<Error | null>(null);
async function fetchOrders() {
loading.value = true;
error.value = null;
try {
orders.value = await client.get<Order[]>('orders').getBody();
} catch (e) {
error.value = e as Error;
} finally {
loading.value = false;
}
}
onMounted(fetchOrders);
return { orders, loading, error, fetchOrders };
}
With Pinia
// stores/orders.ts
import { defineStore } from 'pinia';
export const useOrdersStore = defineStore('orders', {
state: () => ({ items: [] as Order[] }),
actions: {
async fetchAll() {
this.items = await client.get<Order[]>('orders').getBody();
},
async create(input: OrderInput) {
const order = await client.post<Order>('orders', input).getBody();
this.items.push(order);
},
async update(id: string, patch: Partial<OrderInput>) {
await client.patch(`orders/${id}`, patch).getBody();
await this.fetchAll();
},
},
});
Node.js
@opra/client runs in Node.js 18+ without any polyfills — fetch is available natively:
import { OpraHttpClient } from '@opra/client';
const client = new OpraHttpClient('https://api.example.com', {
defaults: {
headers: new Headers({ Authorization: `Bearer ${process.env.API_TOKEN}` }),
},
});
// CLI / script usage — one-shot
const orders = await client.get<Order[]>('orders').getBody();
// Batch — saves round-trips in background jobs
const r1 = client.get<Customer>('customers@1');
const r2 = client.get<Order[]>('orders').param('customerId', 1);
const [res1, res2] = await client.bundle([r1, r2]).getResponses();
See also
OpraHttpClient— full constructor and method referenceHttpRequestObservable— fluent builder returned by every request methodHttpBundleObservable— returned bybundle()andtransaction()- Interceptors — per-request middleware for auth, logging, and retry
- Angular Integration — using OPRA client inside Angular's DI and HttpClient pipeline