HTTP Service Clients
Why this matters
Feature services need to fetch data from external HTTP APIs (backend services, third-party APIs). Without a structured approach, you end up with raw fetch() calls scattered across the codebase, no type safety on request or response shapes, and inconsistent error handling.
The pattern here uses openapi-fetch with types generated from OpenAPI/Swagger specs. The key benefit: a wrong URL path, missing parameter, or incorrect request body is a TypeScript error at the call site — caught before the code runs, not in production logs.
What goes wrong without this:
- Typos in API path strings produce runtime errors that are hard to trace
- Response shapes are typed as
anyorunknown, so data errors surface far from where the call was made - Auth headers are added inconsistently across different fetch calls
- When an API changes, there's no automated way to find all affected call sites
Where service clients fit in the architecture
feature service (libs/feature-X/src/services/)
↓ imports
service client (src/lib/services/<svc>/client.ts)
↓ uses
generated OpenAPI schema (src/lib/services/<svc>/schema.d.ts)Generated schemas and clients are shared infrastructure — they live in src/lib/services/<svc>/, not inside any single feature lib. Feature services import them to fetch data, then transform that data into view models. Remote functions in src/routes/api/ also import clients directly for 1:1 endpoint mappings.
Never import a service client from a client-side component — these modules read PRIVATE_* environment variables and are server-only.
Prerequisites
pnpm add openapi-fetch
pnpm add -D openapi-typescript picocolorsFile layout
src/lib/services/
├── middleware.ts # shared auth/identity middleware
├── items/
│ ├── items-api-schema.d.ts # generated from Swagger spec — do not edit
│ └── items-api-client.ts # configured client
└── files/
├── files-api-schema.d.ts
└── files-api-client.tsStep 1: Generate types from the Swagger spec
Create a codegen script at cli/swagger-typegen.ts. This script fetches the OpenAPI spec from each upstream API and generates TypeScript types:
import { execSync } from "node:child_process";
import { join } from "node:path";
import pc from "picocolors";
interface SwaggerSource {
url: string;
name: string;
outputDir: string;
outputFilename: string;
}
const sources: SwaggerSource[] = [
{
url: "https://items.acme.example.com/swagger/v1/swagger.json",
name: "items-api",
outputDir: "./src/lib/services/items",
outputFilename: "items-api-schema.d.ts",
},
];
function processSource(source: SwaggerSource): boolean {
const outputPath = join(source.outputDir, source.outputFilename);
try {
execSync(`openapi-typescript "${source.url}" -o "${outputPath}"`, { stdio: "pipe" });
console.log(pc.green(`+ ${source.name}`));
return true;
} catch (error) {
const stderr = error instanceof Error ? error.message : String(error);
if (stderr.toLowerCase().includes("econnrefused")) {
console.error(pc.red(`x ${source.name}`) + pc.dim(` - cannot connect to ${source.url}`));
} else {
console.error(pc.red(`x ${source.name}`) + pc.dim(" - failed to generate types"));
}
return false;
}
}
console.log(pc.bold("Generating swagger types...\n"));
const failed = sources.filter((source) => !processSource(source));
if (failed.length > 0) {
console.log(pc.red(`\nFailed to generate types for ${failed.length} source(s).\n`));
process.exit(1);
}Add the script to package.json and run it whenever an upstream API changes:
{
"scripts": {
"swagger-typegen": "node cli/swagger-typegen.ts"
}
}pnpm swagger-typegenThe generated *-schema.d.ts files are committed to the repo. When the upstream API changes, regenerate them and TypeScript will immediately flag all affected call sites.
Step 2: Shared middleware
A single Middleware injects standard client-identity headers on every request. Put it in src/lib/services/middleware.ts:
import type { Middleware } from "openapi-fetch";
export const authMiddleware: Middleware = {
async onRequest({ request }) {
request.headers.set("X-Acme-Client", "acme-app");
request.headers.set("X-Acme-On-Behalf-Of", "acme-app-api@acme.example.com");
return request;
},
async onResponse({ response }) {
return response;
},
async onError({ error }) {
return new Error("Request failed", { cause: error });
},
};Step 3: Configure the client
createClient<paths> is typed by the generated paths. The baseUrl comes from a PRIVATE_* env var so a missing URL throws at use time, not at import time:
// src/lib/services/items/items-api-client.ts
import createClient from "openapi-fetch";
import { env } from "$env/dynamic/private";
import { authMiddleware } from "../middleware";
import type { components, paths } from "./items-api-schema";
export const itemsApiClient = createClient<paths>({
baseUrl: env.PRIVATE_ITEMS_API_URL,
});
itemsApiClient.use(authMiddleware);
// Re-export schema types that callers need
export type ItemStatus = components["schemas"]["ItemStatus"];Step 4: Call the client from a feature service or remote function
Always destructure { data, response } and check both. openapi-fetch does not throw on non-2xx status — response.ok is false, and data is undefined. Missing this check silently swallows errors.
const { data, response } = await itemsApiClient.POST("/items", {
body: { name, status: "active" as ItemStatus },
params: { header: { "X-Acme-Tenant-Key": `workspace=${itemId}` } },
});
if (!data || !response.ok) {
error(500, "Failed to create item");
}The as ItemStatus cast is safe here because ItemStatus is derived from the generated schema — it's not a hand-declared type. See service-clients.md step 3 for how ItemStatus is re-exported from the client module.
Step 5 (optional): Instrumented fetch for non-OpenAPI calls
For HTTP calls that have no Swagger spec, a thin ky wrapper that logs a trace ID and request duration keeps observability consistent. Create src/lib/server/http-client.ts:
import ky from "ky";
import { logger } from "$lib/logger";
export const httpClient = ky.extend({
hooks: {
beforeRequest: [
(request) => {
const traceId = crypto.randomUUID();
request.headers.set("x-trace-id", traceId);
request.headers.set("x-request-start", String(performance.now()));
logger.debug(
{ event: "http.client.start", traceId, method: request.method, url: request.url },
"HTTP request started",
);
},
],
afterResponse: [
(request, _options, response) => {
const start = Number(request.headers.get("x-request-start"));
logger.debug(
{
event: "http.client.success",
traceId: request.headers.get("x-trace-id") ?? undefined,
durationMs: Math.round(performance.now() - start),
url: request.url,
status: response.status,
},
"HTTP request completed",
);
return response;
},
],
},
});Install with pnpm add ky.
Verify
pnpm swagger-typegen # generates src/lib/services/items/items-api-schema.d.ts
pnpm run check # client + all call sites type-check against the generated paths