SearchCtrlK

  • ↑↓navigate
  • ↵open
  • escclose

Can’t find it? Contact us

Build a public JSON API

A JSON API other developers call with a bearer token, answering failures as problems, safe to retry, described by OpenAPI and shipped with a typed client.

After reading this guide, you will know

  • How to build a public JSON API from the HTTP API and identity guides, in order
  • Where the caller's identity meets the rate limit and the idempotency scope
  • How to check that authentication runs before every other guard

At the end of this recipe you have a books API that developers outside your app call with a bearer token: every failure is an RFC 9457 problem, every create is safe to retry, the reference is an OpenAPI document built from the same schemas, and callers get a typed client. Each linked guide lists the packages it installs; the code on this page adds:

Terminal
npm add @sdxc/result remix

Serve the endpoints

Every later step extends the resource, its problem catalog and its rate limit, so build them first with Build a JSON API with problem details.

Authenticate callers

A public API needs to know who calls it before it can budget or scope anything. Verify each bearer token with the authenticate function from Verify on the other side, with your API's URL as the audience, and add an unauthorized entry (status 401) to the problem catalog. Then publish the verified token as ctx.caller, so every later guard reads it:

app/http/middleware/authenticate-caller.ts
import type { Middleware } from "remix/router";

import type { ServiceToken } from "~/app/services/auth/service-token";

import { isFailure } from "@sdxc/result";
import { createContextKey } from "remix/router";

import { authenticate } from "~/app/services/auth/authenticate";
import problems from "~/config/problems";

export const Caller = createContextKey<ServiceToken>();

declare module "remix/router" {
	interface RequestContext {
		caller: ServiceToken;
	}
}

export const authenticateCaller: Middleware = async (ctx, next) => {
	let token = await authenticate(ctx.request);
	if (isFailure(token)) {
		let headers = { "WWW-Authenticate": 'Bearer error="invalid_token"' };
		return problems.unauthorized({}, { headers });
	}

	ctx.set(Caller, token.data, { property: "caller" });
	return next();
};

Make each book route's guards [authenticateCaller, apiRateLimit()], with the limiter from Rate limit the API keyed on ctx.caller.subject, so each caller spends its own budget. When your callers hold keys you issue rather than tokens, Give your users API keys publishes ctx.apiKey in place of this step.

Make writes safe to retry

A caller across the internet retries timeouts and edits stale copies. Follow Idempotent writes and JSON Merge Patch, scoping the keys on the caller: `caller:${ctx.caller.subject}`.

Publish the reference

Developers read the contract before they call it. Follow Describe your API with OpenAPI, declaring the bearer() scheme, and map its route without guards, so anyone can read it.

Ship a typed client

Follow Write a typed API client: the catalog and schemas move into a contract both sides import, and callers get a client that attaches the token, sends idempotency keys and turns problems into typed failures.

Test the guards together

A request without a token is refused before the limiter counts it, as a problem from your catalog:

app/initializers/router.test.ts
import { expect, test } from "vitest";

import { createWebRouter } from "~/app/initializers/router";

test("a request without a token answers 401 before the rate limit", async () => {
	let response = await createWebRouter().fetch(
		"https://books.example.com/api/books",
	);

	expect(response.status).toBe(401);
	expect(response.headers.get("WWW-Authenticate")).toContain("invalid_token");
	expect(response.headers.get("RateLimit-Policy")).toBeNull();
	expect(await response.json()).toMatchObject({
		type: "https://books.example.com/docs/errors/unauthorized",
	});
});

Where to go next

Documentation

Can’t find it? Contact us