1.0.11 • Published 11 months ago

api-bind v1.0.11

Weekly downloads
-
License
MIT
Repository
github
Last release
11 months ago

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.

1.0.11

11 months ago

1.0.9

12 months ago

1.0.10

12 months ago

1.0.8

1 year ago

1.0.7

1 year ago

1.0.6

1 year ago

1.0.5

1 year ago

1.0.4

1 year ago

1.0.3

1 year ago

1.0.2

1 year ago

1.0.1

1 year ago

1.0.0

1 year ago