From cd2b8761098901d849b1fa3a71b500d0092dc584 Mon Sep 17 00:00:00 2001 From: Christopher Blanchard Date: Mon, 28 Sep 2026 16:16:27 +0000 Subject: [PATCH] feat: publish RFC 9727 api-catalog and homepage Link headers Served from Express on postcodes.io and api.postcodes.io. express.static skips dotfiles, so the catalog is a route rather than a static file. --- api/config/routes.ts | 3 ++ api/config/well_known.ts | 55 ++++++++++++++++++++++++++++++++++ test/well_known.integration.ts | 48 +++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+) create mode 100644 api/config/well_known.ts create mode 100644 test/well_known.integration.ts diff --git a/api/config/routes.ts b/api/config/routes.ts index 637f9d83d..a7f1d3e7e 100644 --- a/api/config/routes.ts +++ b/api/config/routes.ts @@ -8,6 +8,7 @@ import * as postcodes from "../app/controllers/postcodes_controller"; import * as scottishPostcodes from "../app/controllers/scottish_postcodes_controller"; import * as terminatedPostcodes from "../app/controllers/terminated_postcodes_controller"; import { getConfig } from "./config"; +import { apiCatalog, homepageLink } from "./well_known"; export const routes = (app: Express): void => { const router = express.Router(); @@ -39,6 +40,8 @@ export const routes = (app: Express): void => { const docsBuildPath = join(__dirname, "../../build"); + router.get("/.well-known/api-catalog", apiCatalog); + router.get("/", homepageLink); router.use(express.static(docsBuildPath)); const { urlPrefix } = getConfig(); diff --git a/api/config/well_known.ts b/api/config/well_known.ts new file mode 100644 index 000000000..c886fa1f6 --- /dev/null +++ b/api/config/well_known.ts @@ -0,0 +1,55 @@ +import { RequestHandler } from "express"; + +// RFC 9727 API catalog and RFC 8288 homepage Link header. Served on both +// postcodes.io and api.postcodes.io (same app). + +export const API_CATALOG_PATH = "/.well-known/api-catalog"; + +export const catalog = { + linkset: [ + { + anchor: "https://api.postcodes.io", + "service-desc": [ + { + href: "https://api.postcodes.io/openapi.json", + type: "application/json", + }, + ], + "service-doc": [ + { href: "https://postcodes.io/docs/api", type: "text/html" }, + ], + status: [ + { href: "https://api.postcodes.io/ready", type: "application/json" }, + ], + }, + ], +}; + +const CATALOG_BODY = Buffer.from(JSON.stringify(catalog, null, 2)); + +export const CATALOG_CONTENT_TYPE = + 'application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727"'; + +export const HOMEPAGE_LINK = [ + `<${API_CATALOG_PATH}>; rel="api-catalog"`, + '; rel="service-desc"; type="application/json"', + '; rel="service-doc"; type="text/html"', +].join(", "); + +// Express answers HEAD with this handler too, keeping the Link header on HEAD. +// A Buffer body keeps the Content-Type exact: res.json would replace it and a +// string body gets "; charset=utf-8" appended. +export const apiCatalog: RequestHandler = (_, response) => { + response + .set({ + "Content-Type": CATALOG_CONTENT_TYPE, + Link: `<${API_CATALOG_PATH}>; rel="api-catalog"`, + "Cache-Control": "public, max-age=3600", + }) + .send(CATALOG_BODY); +}; + +export const homepageLink: RequestHandler = (_, response, next) => { + response.set("Link", HOMEPAGE_LINK); + next(); +}; diff --git a/test/well_known.integration.ts b/test/well_known.integration.ts new file mode 100644 index 000000000..217c73d28 --- /dev/null +++ b/test/well_known.integration.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import request from "supertest"; +import { postcodesioApplication } from "./helper"; +import { CATALOG_CONTENT_TYPE } from "../api/config/well_known"; + +const app = postcodesioApplication(); + +describe("/.well-known/api-catalog", () => { + it("serves an RFC 9727 linkset", async () => { + const { headers, text } = await request(app) + .get("/.well-known/api-catalog") + .set("Accept", "application/linkset+json, application/json") + .expect(200); + expect(headers["content-type"]).toBe(CATALOG_CONTENT_TYPE); + expect(headers["link"]).toBe( + '; rel="api-catalog"' + ); + expect(headers["access-control-allow-origin"]).toBe("*"); + const [entry] = JSON.parse(text).linkset; + expect(entry.anchor).toBe("https://api.postcodes.io"); + for (const rel of ["service-desc", "service-doc", "status"]) { + expect(entry[rel][0].href).toMatch(/^https:\/\//); + } + }); + + it("carries the api-catalog Link on HEAD", async () => { + const { headers } = await request(app) + .head("/.well-known/api-catalog") + .expect(200); + expect(headers["link"]).toBe( + '; rel="api-catalog"' + ); + }); +}); + +describe("homepage", () => { + it("carries a Link header to the api-catalog, spec and docs", async () => { + const { headers } = await request(app).get("/"); + const link = headers["link"]; + expect(link).toContain('; rel="api-catalog"'); + expect(link).toContain( + '; rel="service-desc"' + ); + expect(link).toContain( + '; rel="service-doc"' + ); + }); +});