sdxc

[ HTTP & responses ]

@sdxc/api-client

Base class for HTTP API clients: one origin, verb methods, shared hooks

npm add @sdxc/api-client
pnpm add @sdxc/api-client
yarn add @sdxc/api-client
bun add @sdxc/api-client
Installs with
@sdxc/trace-context

Base class for HTTP API clients: one origin, verb methods, shared hooks.

A client of a remote service is a subclass of APIClient: it names the origin once, calls get/post/put/patch/delete with paths, and overrides before and after to shape every request and response in one place.

Installation

npm add @sdxc/api-client

Requests carry the running trace through @sdxc/trace-context, installed with it.

Usage

Define a client for one service

import { APIClient } from "@sdxc/api-client";

class Catalog extends APIClient {
	constructor() {
		super(new URL("https://api.example.com/v1/"));
	}

	async product(id: string): Promise<Response> {
		return await this.get(`products/${id}`);
	}
}

let catalog = new Catalog();
let response = await catalog.product("42");

Attach what every request needs

before runs on every request, whichever verb sent it, so a credential is set once.

import { APIClient } from "@sdxc/api-client";

class Catalog extends APIClient {
	constructor(private token: string) {
		super(new URL("https://api.example.com/v1/"));
	}

	protected override async before(request: Request): Promise<Request> {
		request.headers.set("Authorization", `Bearer ${this.token}`);
		return request;
	}
}

Paths resolve against the base URL

Paths are resolved with new URL(path, base): a leading slash anchors at the origin, and a relative path continues the base URL's own path.

import { APIClient } from "@sdxc/api-client";

let client = new APIClient(new URL("https://api.example.com/v1/"));

await client.get("products"); // https://api.example.com/v1/products
await client.get("/products"); // https://api.example.com/products

API

new APIClient(baseURL: URL)

A client bound to one origin. Subclass it to give a service its own methods.

client.fetch(path: string, init?: RequestInit): Promise<Response>

Resolves path against the base URL, writes the current trace headers, runs before, sends the request with the global fetch, and returns what after hands back. Every verb method calls it.

client.get, client.post, client.put, client.patch, client.delete

(path: string, init?: APIClientInit) => Promise<Response>. Each sets its own method, so client.post("products", { body }) is client.fetch("products", { body, method: "POST" }).

protected before(request: Request): Promise<Request>

Returns the request to send. It sees the trace headers already written, so it can read, replace or delete them. The default returns the request as it arrived.

protected after(request: Request, response: Response): Promise<Response>

Returns the response to hand to the caller, with the request that produced it. The default returns the response as it arrived.

protected propagateTrace: "all" | "traceparent" | "none"

Which W3C Trace Context headers a request carries when the running invocation has a trace: traceparent and tracestate ("all", the default), traceparent alone, or neither. A traceparent passed in init is kept.

protected baseURL: URL

The origin paths resolve against, readable from a subclass.

Types

APIClientInit

Omit<RequestInit, "method">: the options a verb method accepts.

Pattern: Methods That Return Data

Parsing lives in the method, so the path, the payload and the type a caller receives stay together.

import { APIClient } from "@sdxc/api-client";

interface Product {
	id: string;
	name: string;
}

class Catalog extends APIClient {
	constructor() {
		super(new URL("https://api.example.com/v1/"));
	}

	async list(): Promise<Product[]> {
		let response = await this.get("products");
		return (await response.json()) as Product[];
	}

	async create(name: string): Promise<Product> {
		let response = await this.post("products", {
			headers: { "Content-Type": "application/json" },
			body: JSON.stringify({ name }),
		});
		return (await response.json()) as Product;
	}

	async remove(id: string): Promise<boolean> {
		let response = await this.delete(`products/${id}`);
		return response.ok;
	}
}

let catalog = new Catalog();
let product = await catalog.create("Lamp");
await catalog.remove(product.id);

Pattern: One Error Policy For Every Call

An error status reaches the caller as an ordinary Response. after states the policy once, with the request at hand: here a missing search answers an empty list, and any other failure becomes a value the caller checks.

import { APIClient } from "@sdxc/api-client";

class Search extends APIClient {
	constructor() {
		super(new URL("https://search.example.com"));
	}

	protected override async after(request: Request, response: Response): Promise<Response> {
		if (response.status === 404 && new URL(request.url).pathname === "/search") {
			return Response.json({ items: [] });
		}
		return response;
	}

	async search(query: string): Promise<{ items: string[] } | { error: number }> {
		let response = await this.get(`/search?q=${encodeURIComponent(query)}`);
		if (!response.ok) return { error: response.status };
		return (await response.json()) as { items: string[] };
	}
}

Read the body inside after from response.clone(), so the caller still receives an unread stream.

Pattern: Carry The Trace Across Services

Inside a traced invocation every request names that invocation's span as its parent, so the callee's logs join the caller's trace. propagateTrace narrows what a third party receives.

import { APIClient } from "@sdxc/api-client";
import { trace } from "@sdxc/trace-context/middleware";
import { createRouter } from "remix/router";

class Inventory extends APIClient {
	constructor() {
		super(new URL("https://inventory.internal.example.com"));
	}
}

class Payments extends APIClient {
	protected override readonly propagateTrace = "traceparent";

	constructor() {
		super(new URL("https://payments.example.com"));
	}
}

let inventory = new Inventory();
let payments = new Payments();

let router = createRouter({ middleware: [trace()] });

router.post("/checkout", async () => {
	await inventory.post("/reservations"); // traceparent and tracestate
	await payments.post("/charges"); // traceparent only
	return new Response(null, { status: 204 });
});

Outside a traced invocation the requests carry no trace headers.

Pattern: Stubbing The Origin In Tests

Requests go through the global fetch, so a test intercepts them with MSW and exercises the real client.

import { APIClient } from "@sdxc/api-client";
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { afterAll, afterEach, beforeAll, expect, test } from "vitest";

let server = setupServer(
	http.get("https://api.example.com/products", () => {
		return HttpResponse.json([{ id: "1", name: "Lamp" }]);
	}),
);

beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

test("lists products", async () => {
	let client = new APIClient(new URL("https://api.example.com"));
	let response = await client.get("/products");

	expect(await response.json()).toEqual([{ id: "1", name: "Lamp" }]);
});

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.