Typed SDK factory for web APIs
- Typed requests based on custom API schemas
- Environment-agnostic interface
- Zero dependencies
Contents: RequestService · Schema definition · Shorthand methods · Splitting into scopes · Custom request handler · Runtime validation
The RequestService class helps create a thin type-safe entrypoint to an API:
import{RequestService}from"sdkify";constservice=newRequestService<APISchema>(requestHandler);The constructor accepts a custom requestHandler. A specific request handler isn't built into the package, since it can vary in many ways depending on the purpose and environment of the application: it can make use of fetch, axios, logging, default headers, or whatever necessary.
The purpose of RequestService is to offer a single environment-agnostic interface to request handling on top of a typed API schema.
⬥ A typed schema allows to prevalidate request inputs at compile-time and highlight mismatches in a type-aware IDE.
⬥ The environment-agnostic interface works consistently throughout the client and the server:
constservice=newRequestService<APISchema>(browserHandler);constservice=newRequestService<APISchema>(serverHandler);The same API with different environment-specific request handlers under the hood results in reusable isomorphic code:
// browser or serverconst{ ok, status, body }=awaitservice.send("GET /items");The APISchema type used with the RequestService constructor is a custom schema outlining the types of requests and responses within an API. The example below shows what such a schema may look like.
importtype{Schema}from"sdkify";// Wrapping into the `Schema` generic type is optional, but// this helps validate the basic schema structureexporttypeAPISchema=Schema<{// A schema key can be any unique string, for an HTTP API// a pair of a method and a path can serve this purpose"GET /items/:id": {request: {// `params` can be omitted if the URL path is fixed and// has no parameter placeholdersparams: {id: number;};query?: {mode?: "compact"|"full";};};response: {body: {id: number;name?: string;};};};"POST /items/:id": {// ...};"GET /items": {// ...};// ... and so forth}>;⬥ Schema keys should be unique strings, not necessarily a pair of an HTTP method and a path, as in the example above, but this is a handy naming convention for an HTTP API. If a schema key doesn't match this pattern, method and path (or url) have to be explicitly specified in the schema entry.
With such a schema assigned to service, calls to its send() method will be prevalidated against this schema at compile time, which also means that a type-aware IDE will warn of type mismatches or typos in the parameters:
const{ ok, status, body }=awaitservice.send("GET /items/:id",{// ▾ { id: number }params: {id: 10,},// ▾ { mode?: "compact" | "full" }query: {mode: "full",},});The options passed as the second parameter to send() are validated as APISchema["GET /items/:id"] based on the schema type passed to the RequestService constructor and the first parameter passed to send().
The API schema keys can be mapped to custom method names:
constapi=service.getEntry({getItems: "GET /items",getItem: "GET /items/:id",setItem: "POST /items/:id",});With such a mapping in place, service.send("GET /items/:id", { ... }) has another equivalent form:
constresponse=awaitapi.getItem({// ▾ { id: number }params: {id: 10,},// ▾ { mode?: "compact" | "full" }query: {mode: "full",},});⬥ For API methods controlled only with query parameters without path placeholders, there's also another way to define custom methods: getQueryEntry(). It can be used similarly to getEntry() above, but the returned methods will only accept query parameters without the need to nest them into the query key.
The getEntry() method described above doesn't have to take all of the API schema keys at once. The API methods can be split into logical scopes:
constapi={users: service.getEntry({getList: "GET /users",getInfo: "GET /users/:id",}),items: service.getEntry({getList: "GET /items",getInfo: "GET /items/:id",setInfo: "POST /items/:id",}),};constuserList=awaitapi.users.getList();constfirstUser=awaitapi.users.getInfo({params: {id: userList[0].id}});As shown above, the RequestService constructor takes a custom request handler as a parameter. Internal independence of RequestService from a fixed built-in request handler allows to handle requests of all sorts and environments (the browser or node) without locking in with a certain approach to handling requests.
Here's an example of a basic JSON request handler that can be passed to RequestService:
import{RequestHandler,RequestError,RequestService,getRequestAction,toStringValueMap,}from"sdkify";importtype{APISchema}from"./APISchema";functiongetRequestHandler(endpoint: string): RequestHandler{returnasyncfunction(target,request){const{ method, url }=getRequestAction({ request, target, endpoint });constresponse=awaitfetch(url,{
method,headers: toStringValueMap(request?.headers),body: request?.body ? JSON.stringify(request?.body) : null,});const{ ok, status, statusText }=response;if(!ok)thrownewRequestError({ status, statusText });try{constbody=awaitresponse.json();return{ ok, status, statusText, body };}catch(error){thrownewRequestError(error);}};}exportconstserverService=newRequestService<APISchema>(getRequestHandler("https://api.example.com"));// Assuming that the given API is proxied by the server to// the browser via `/api`exportconstbrowserService=newRequestService<APISchema>(getRequestHandler("/api"));To meet the needs of a specific use case, the request handler's code can certainly depart from the example above (which is the primary reason why it's not hardwired into the package).
Runtime validation of the request and response can be implemented within a custom request handler discussed above. Here's how the basic JSON request handler from the previous section can be modified to validate its input and output with Zod or, similarly, with another validation lib:
import {
RequestHandler,
RequestError,
RequestService,
getRequestAction,
toStringValueMap,
} from "sdkify";
- import type { APISchema } from "./APISchema";+ import { apiSchema, type APISchema } from "./apiSchema"; // defined with Zod
function getRequestHandler(endpoint: string): RequestHandler {
return async function(target, request) {
+ try {+ schema[target]?.request?.parse(request);+ }+ catch {+ throw new RequestError({ statusText: "Invalid request data" });+ }
const { method, url } = getRequestAction({ request, target, endpoint });
const response = await fetch(url, {
method,
headers: toStringValueMap(request?.headers),
body: request?.body ? JSON.stringify(request?.body) : null,
});
const { ok, status, statusText } = response;
if (!ok) throw new RequestError({ status, statusText });
try {
const body = await response.json();
+ try {+ schema[target]?.response?.parse(body);+ }+ catch {+ throw new RequestError({ statusText: "Invalid response data" });+ }
return { ok, status, statusText, body };
}
catch (error) {
throw new RequestError(error);
}
};
}
export const serverService = new RequestService<APISchema>(
getRequestHandler("https://api.example.com")
);
// Assuming that the given API is proxied by the server to
// the browser via `/api`
export const browserService = new RequestService<APISchema>(
getRequestHandler("/api")
);Here's an example of what the API schema definition with a validation library may look like:
import{z}from"zod";exportconstapiSchema={"GET /items/:id": {request: z.object({params: z.object({id: z.coerce.number(),}),query: z.optional(z.object({mode: z.optional(z.string()),})),}),response: z.object({id: z.coerce.number(),name: z.optional(z.string()),}),},};exporttypeAPISchema={[K1inkeyoftypeofapiSchema]: {[K2inkeyof(typeofapiSchema)[K1]]: z.infer<(typeofapiSchema)[K1][K2]>;};};