Chapter 21: API Client#
1. Calling External APIs Without the Boilerplate#
Your application calls a payment gateway. A shipping provider. A weather service. A CRM. Every call needs the same setup: base URL, auth header, error handling, JSON parsing, timeout.
Tina4 provides an Api class, a small HTTP client over Node's built-in node:http/node:https that handles the repetitive parts. It covers GET, POST, PUT, PATCH, and DELETE with JSON serialization, auth headers set once on the instance, and a consistent response format, all with no external dependencies.
2. The Api Class#
Import Api from @tina4/core and construct an instance:
import { Api } from "@tina4/core";โconst api = new Api("https://api.example.com/v2");Api is a ready-to-use HTTP client. Construct it once. Reuse the instance everywhere.
3. Configuring the Client#
The constructor takes the base URL, an optional Authorization header value, and an optional timeout in seconds (default 30). Auth and custom headers can also be set after construction:
import { Api } from "@tina4/core";โ// Positional form: (baseUrl, authHeader, timeoutSeconds)const api = new Api("https://api.example.com/v2", "", 10 /* seconds */);โapi.addHeaders({ "X-App-Version": "1.0.0" });api.setBearerToken(process.env.TINA4_API_KEY ?? "");โ// Or the options-bag form (recommended):const api2 = new Api("https://api.example.com/v2", { bearerToken: process.env.TINA4_API_KEY, headers: { "X-App-Version": "1.0.0" }, timeout: 10,});| Constructor arg | Default | Description |
|---|---|---|
baseUrl | "" | Prepended to every request path |
authHeaderOrOptions | "" | An Authorization header value, or an options bag (bearerToken, username/password, headers, timeout, verifySsl) |
timeout | 30 | Request timeout in seconds |
Instance setters:
| Method | Description |
|---|---|
addHeaders(headers) | Merge headers sent with every request |
setBearerToken(token) | Set Authorization: Bearer <token> |
setBasicAuth(user, pass) | Set HTTP Basic auth |
setIgnoreSsl(true) | Skip TLS verification (dev / self-signed certs only) |
4. GET Requests#
import { Api } from "@tina4/core";โconst api = new Api("https://api.example.com");const response = await api.get("/products");โif (response.error === null && response.http_code === 200) { const products = response.body as unknown[]; console.log(`Fetched ${products.length} products`);} else { console.error(`Error ${response.http_code}: ${response.error}`);}Pass query parameters as a flat object in the second argument:
const response = await api.get("/products", { category: "Electronics", page: "2", limit: "20",});// Requests: GET /products?category=Electronics&page=2&limit=20Query values are strings (they go straight into the query string).
5. POST Requests#
import { Api } from "@tina4/core";โconst api = new Api("https://api.example.com");const response = await api.post("/orders", { customerId: 15, items: [ { sku: "KB-001", qty: 1, price: 79.99 }, { sku: "HDMI-2M", qty: 2, price: 12.99 } ], shippingAddress: { line1: "123 Main St", city: "Springfield", country: "US" }});โif (response.error === null && response.http_code === 201) { const order = response.body as { orderId: string }; console.log(`Order created: ${order.orderId}`);} else { console.error("Order failed:", response.error ?? response.http_code);}The body is serialized as JSON automatically. The Content-Type: application/json header is set for you (override it with the optional third argument).
6. PUT and PATCH Requests#
import { Api } from "@tina4/core";โconst api = new Api("https://api.example.com");โ// Replace the entire resourceconst putResponse = await api.put("/products/42", { name: "Wireless Keyboard Pro", price: 89.99, inStock: true, category: "Electronics"});โ// Update specific fields onlyconst patchResponse = await api.patch("/products/42", { price: 74.99});โif (putResponse.error === null && putResponse.http_code === 200) { console.log("Product updated:", putResponse.body);}7. DELETE Requests#
import { Api } from "@tina4/core";โconst api = new Api("https://api.example.com");const response = await api.delete("/products/42");โif (response.http_code === 200 || response.http_code === 204) { console.log("Product deleted");} else if (response.http_code === 404) { console.log("Product not found");} else { console.error("Delete failed:", response.error ?? response.http_code);}8. Response Format#
Every method returns the same ApiResult shape:
interface ApiResult { http_code: number | null; // HTTP status code, or null if the request never reached the server body: unknown; // Parsed JSON body, or the raw string if not JSON headers: Record<string, string>; // Response headers error: string | null; // Non-null on transport failure or timeout}error is non-null only on a transport-level failure (connection refused, DNS, timeout). An HTTP error response (e.g. 404, 500) still arrives with error: null, so inspect http_code to branch on it.
const response = await api.get("/products/999");โif (response.error !== null) { // Never reached the server return res.status(502).json({ error: "Upstream unreachable" });}โif (response.http_code === 404) { return res.status(404).json({ error: "Product not found upstream" });}if (response.http_code !== 200) { return res.status(502).json({ error: "Upstream service error" });}โreturn res.json(response.body);9. Per-Request Content Type and Generic Requests#
post/put/patch take an optional third argument to override the content type, and sendRequest() lets you issue any method:
import { Api } from "@tina4/core";โconst api = new Api("https://api.example.com");โ// Send a non-JSON bodyawait api.post("/upload", "<xml/>", "application/xml");โ// Any HTTP methodconst options = await api.sendRequest("OPTIONS", "/users");Headers configured with addHeaders() / setBearerToken() are sent on every request from that instance. For different auth, construct a second Api instance.
10. Using Api Inside Route Handlers#
Proxy or transform external API calls inside your routes. Construct the client once at module load and reuse it:
import { get } from "@tina4/core";import { Api } from "@tina4/core";โconst weatherApi = new Api("https://api.openweathermap.org/data/2.5");weatherApi.addHeaders({ "Accept": "application/json" });โget("/api/weather/{city}", async (req, res) => { const city = req.params.city;โ const response = await weatherApi.get("/weather", { q: city, appid: process.env.OPENWEATHER_API_KEY ?? "", units: "metric" });โ if (response.error !== null || response.http_code !== 200) { return res.status(502).json({ error: `Weather service error: ${response.error ?? response.http_code}` }); }โ const weather = response.body as { name: string; main: { temp: number; humidity: number }; weather: { description: string }[]; };โ return res.json({ city: weather.name, temperature: weather.main.temp, humidity: weather.main.humidity, description: weather.weather[0]?.description ?? "unknown" });});11. Exercise: GitHub User Profile Proxy#
Build a route that fetches a GitHub user profile via an Api client and returns a simplified version.
Requirements#
- Construct an
Apiwithhttps://api.github.comas the base URL and aUser-Agentheader (GitHub requires one) - Create a
GET /api/github/{username}route that fetches the user's public profile - Return only:
login,name,public_repos,followers,following,bio, andavatar_url - Return
404with a message if the GitHub user does not exist
Test with:#
curl http://localhost:7148/api/github/torvaldscurl http://localhost:7148/api/github/this-user-does-not-exist-xyzabc12. Solution#
src/services/github.ts:
import { Api } from "@tina4/core";โexport const githubApi = new Api("https://api.github.com", "", 8 /* seconds */);githubApi.addHeaders({ "User-Agent": "tina4-book-example/1.0", "Accept": "application/vnd.github+json"});src/routes/github.ts:
import { get } from "@tina4/core";import { githubApi } from "../services/github";โinterface GitHubUser { login: string; name: string | null; bio: string | null; public_repos: number; followers: number; following: number; avatar_url: string;}โget("/api/github/{username}", async (req, res) => { const { username } = req.params;โ const response = await githubApi.get(`/users/${username}`);โ if (response.error !== null) { return res.status(502).json({ error: "GitHub API unreachable", detail: response.error }); } if (response.http_code === 404) { return res.status(404).json({ error: `GitHub user '${username}' not found` }); } if (response.http_code !== 200) { return res.status(502).json({ error: "GitHub API error", detail: response.http_code }); }โ const user = response.body as GitHubUser;โ return res.json({ login: user.login, name: user.name, bio: user.bio, public_repos: user.public_repos, followers: user.followers, following: user.following, avatar_url: user.avatar_url });});curl http://localhost:7148/api/github/torvalds{ "login": "torvalds", "name": "Linus Torvalds", "bio": null, "public_repos": 7, "followers": 234156, "following": 0, "avatar_url": "https://avatars.githubusercontent.com/u/1024025?v=4"}13. Gotchas#
1. http_code vs error#
error is set only when the request never reaches the server (timeout, connection refused). A 404 or 503 response arrives with error: null, so branch on http_code for those.
Fix: Check response.error first for transport failures, then check response.http_code for HTTP-level branches.
2. Timeout is in seconds#
The constructor's third argument is seconds, not milliseconds: new Api(url, "", 10) is a 10-second timeout. The default is 30 seconds.
Fix: Set an explicit timeout appropriate for your service SLA. For real-time endpoints, 5-10 seconds is usually the right upper bound.
3. Reuse the instance#
Constructing a new Api for every request re-applies headers each time and adds noise.
Fix: Construct one Api per upstream service at module load, configure its headers once, and reuse it.
4. Sending secrets in URLs#
Appending API keys as query parameters (e.g., ?apikey=secret) logs the key to access logs.
Fix: Pass secrets in headers. Use api.setBearerToken(token) or api.addHeaders({ "X-API-Key": key }) once on the instance.
14. Uploading Files (New in 3.13.69)#
upload() posts a multipart/form-data body: a file plus optional text fields. You supply the file two ways through an options object, so your code never stages a temp file first.
import { Api } from "@tina4/core";โconst api = new Api("https://api.example.com", { bearerToken: process.env.API_TOKEN });โ// A file on disk. filename defaults to the basename.await api.upload("/avatars", { filePath: "/tmp/me.png" });โ// In-memory bytes (Buffer or string). Pass a filename so the server sees a real name.const raw = await buildThumbnail(); // Bufferawait api.upload("/avatars", { fileBytes: raw, filename: "me.png", extraFields: { user_id: "42" }, // extra text parts});The options object accepts filePath, fieldName (default "file"), extraFields, headers, fileBytes, and filename. The part's Content-Type is guessed from the filename, falling back to application/octet-stream.
upload() returns the standard ApiResult (http_code, body, headers, error). A missing file, or no source at all, resolves to a clean error result and never throws:
const result = await api.upload("/avatars", { filePath: "/tmp/gone.png" });// { http_code: null, body: null, headers: {}, error: "file not found: /tmp/gone.png" }15. Streaming Downloads (New in 3.13.69)#
download() streams a GET body straight to disk, 64KB at a time. A large export never buffers whole in memory.
const result = await api.download("/reports/2026.csv", "/tmp/2026.csv", { q: "2026" });โif (result.error === null) { console.log("saved to", result.path); // /tmp/2026.csv}The signature is download(path, destPath, params?). The result has no body field. The body went to disk. It carries http_code, headers, error, and path. On success path is your destPath; on any error (no dest, an HTTP error status, or a transport failure) path is null and no file is written.
16. Testing Your Code: the transport Seam (New in 3.13.69)#
The options bag accepts a transport function that fully replaces the node:http/node:https call. Point it at your own function and the code that calls an Api runs in a unit test with no live server.
const api = new Api("https://api.example.com", { transport: async (method, url, headers, body, timeout) => ({ http_code: 200, body: { ok: true }, headers: {}, error: null }),});โconst result = await api.get("/health"); // returns the canned result, opens no socketThe function signature is (method, url, headers, body, timeout) => ApiResult. It may be sync or async and returns the standard result shape.
This seam is for your tests, not Tina4's. The framework's own suite never injects a fake transport: it follows the no-mock rule and drives the real network against a real local server. Reach for transport to test the code that calls an Api, never to stand in for Api itself.
17. The Cookie Jar (New in 3.13.69)#
Set cookies: true and the client keeps a per-instance, in-memory cookie jar. It reads Set-Cookie on each response and replays the accumulated Cookie header on the next request, so a session carries across a login and the calls that follow.
const api = new Api("https://api.example.com", { cookies: true });โawait api.post("/login", { user: "alice", pass: "secret" }); // server sets a session cookieawait api.get("/account"); // the cookie is sent automaticallyThe jar is off by default. It keeps only the leading name=value of each cookie, it is never persisted, and it is scoped to the instance.
18. Redirects and Cross-Origin Safety (New in 3.13.69)#
Bare node:http/node:https does not follow redirects. The client now does, bounded to ten hops. A 301, 302, or 303 on a body-bearing method becomes a GET with the body dropped (matching urllib); 307 and 308 keep the method and body.
On a redirect that crosses to a different origin (a different scheme, host, or port), the client strips the Authorization header and the cookie-jar Cookie header before following. That strip is a security boundary: without it, a call to https://api.example.com/login that redirected to https://evil.example/ would hand your bearer token and session cookie to a host you never authenticated against. Same-origin redirects keep both headers.
You get this on every verb, on upload(), and on download(), with nothing to switch on.