Skip to content

okdecm/api-bind

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

36 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

api-bind

A schema based api binding library.

NOTE: This is a work in progress - some official documentation will eventually come.
The examples given should be enough to throw something together in the mean time.

Getting Started

api-bind is used on both the client and the server to enforce adherence to schema based http request -> response contracts. This simplifies and abstracts a lot of the wireup necessary to create/handle http requests. Being schema based adds the benefit of strict typing, so you can be certain your responses are going to be correct. In the event your schemas do go out of sync, reponses are parsed so your application will be safe from any unexpected data.

Implementation is generic as possible. Whether you want to parse with zod/yup, communicate with fetch/axios, or route with express - as long as it conforms to the configutation, it will work.

Installation

npm i api-bind

Examples

Schemas

import { HTTPSchema, StatusCode } from "api-bind";
import { z } from "zod";

export const getUserSchema = {
	path: "/users/:id",
	method: "get",
	responses: [
		{
			statusCode: 200,
			body: z.object({
				ID: z.number(),
				Email: z.string().email()
			})
		},
		{
			statusCode: 404
		}
	]
} as const satisfies HTTPSchema<z.ZodType>;

Contexts

Declaring context

type Context = {
	database: Database;

	userID?: string;
} & IDisposable;

const databasePool = createDatabasePool();

const router = httpRouter<{
	...
	Context: Context;
}>({
	async createContext(request) {
		const pickedDatabase = await databasePool.pick();

		return {
			database: pickedDatabase.item,
			userID: getUserID(request),
			async dispose() {
				const disposing = [
					pickedDatabase.release()
				];

				await Promise.allSettled(disposing);
			}
		}
	}
});

Disposing of a context

type Context = { ... } & IDisposable;

const router = httpRouter<{
	...
	Context: Context;
}>({
	async cleanup(context)
	{
		await context.dispose();
	}
});

Context assertions

type Context = {
	userID?: string;
};

type ContextWithUserID = Context & {
	userID: string;
};

function hasUserID(context: Context): asserts context is ContextWithUserID
{
	if (!context.userID) throw new AuthorizationError("No user ID");
}

const router = httpRouter<{
	...
	Context: Context;
}>({
	register(path, method, handler)
	{
		app[method](path, async (request, response) =>
		{
			try
			{
				const result = await handler({
					headers: request.headers,
					params: request.params,
					body: request.body
				});

				response.status(result.statusCode).send(result.body);
			}
			catch (e)
			{
				if (isValidationError(e))
				{
					return response.status(ClientError.BadRequest).send(e.message);
				}

				if (isAuthorizationError(e))
				{
					return response.status(ClientError.Unauthorized).send();
				}

				console.error("Failed to handle request due to error", e);

				response.status(500).send();
			}
		});
	}
});

router.route(
	getMyAccountSchema,
	async (request, context) =>
	{
		hasUserID(context);

		const { userID } = context;

		// type of `userID` is string
	}
);

Frontend

Client

import { httpClient, Fn } from "api-bind";
import { z } from "zod";

interface ZodTransform extends Fn
{
	return: z.infer<this["arg0"]>
}

const client = httpClient<z.ZodType, ZodTransform>({
	parse: (schema, value) => {
		return schema.parse(value);
	},

	communicate: async (request) => {
		const response = await fetch(
			"http://localhost:9000" + request.path,
			{
				method: request.method,
				body: request.body as Blob
			}
		);

		const json = await response.json();

		return {
			statusCode: response.status,
			body: json
		}
	}
});

export default client;

Requests

import { getUserSchema } from "@library/api/schemas/users";

const getUserRequest = client.request(getUserSchema);

export async function getUser(id: string) {
	const response = await getUserRequest({
		params: {
			id
		}
	});

	if(response.statusCode === 404) {
		return undefined;
	}

	return response.body;
}

Backend

Router

import { httpRouter, Fn } from "api-bind";
import { z } from "zod";

import Express from "express";

interface ZodTransform extends Fn
{
	return: z.infer<this["arg0"]>
}

export const express = Express();

const router = httpRouter<z.ZodType, ZodTransform>({
	parse: (schema, value) => {
		return schema.parse(value);
	},

	register(path, method, handler) {
		express[method](path, async (request, response) => {
			// Handler will by default:
			// - Parse inputs (and throw appropriate errors on failure)
			try {
				const result = await handler({
					params: request.params,
					body: request.body
				});
	
				response.status(result.statusCode).send(result.body);
			} catch(e) {
				// if(e instanceof ParseError) {
				// 	return response.status(400).send(e.message);
				// }
	
				response.status(500).send();
			}
		});
	}
});

export default router;

Routes

import { router } from "/api/router";
import { getUserSchema } from "@app-library/api/schemas/users";

router.route(getUserSchema, async (request) => {
	const { params } = request;

	const { id } = params;

	if(id === "dec") {
		return {
			statusCode: 200,
			body: {
				ID: 123,
				Email: "dec@dec.dec"
			}
		} as const;
	}

	return {
		statusCode: 404
	} as const;
});

NOTE: if you need to type the router (e.g. if you are taking it as a parameter in a function) - you can do function wireupRoutes(router: ReturnType<typeof httpRouter>).. I'll eventually make this an interface I promise.

About

A schema based api binding library.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published