API strategy
Agent infrastructure
fastify-openapi-glue serviceHandlers operationId guide: wire OpenAPI operations to Fastify handlers, add a drift check, and keep the contract honest in CI.

This guide walks you through wiring fastify-openapi-glue so that every route in your OpenAPI spec resolves to a real handler on your service object — via operationId. By the end you will have a Fastify server that loads a spec, matches each operation to a method on a serviceHandlers object, and fails loudly when the two drift apart.
Prerequisites: Node.js 20 or later (Fastify v5 requires it), familiarity with Fastify, and an OpenAPI 3.0 or 3.1 spec you can edit. Time required: about 30 minutes for a working setup, longer if you are retrofitting an existing codebase.
One warning up front. fastify-openapi-glue uses operationId as the only link between spec and code. Get the naming discipline right on day one and the rest is mechanical. Get it wrong and you will fight silent 404s for weeks.
The glue plugin registers routes from your OpenAPI document and dispatches them to methods on a service object. It also runs request and response validation using the schemas in the spec, which matters more than it sounds — it is how you catch drift between contract and code.
Run the install:
npm install fastify fastify-openapi-glue
Create the project skeleton:
.
├── openapi.yaml
├── service.js
└── server.js
Keep the spec, the service object, and the server entrypoint in separate files from the start. You will thank yourself when the spec grows past 20 endpoints.
operationId is optional in the OpenAPI specification. For fastify-openapi-glue it is mandatory. If an operation has no operationId, the plugin will skip it — the route simply won't be registered, and requests to it will 404 with no useful log line.
Open openapi.yaml and add an operationId to every operation:
paths:
/users:
get:
operationId: listUsers
responses:
'200':
description: A list of users
post:
operationId: createUser
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/NewUser'
responses:
'201':
description: Created
/users/{userId}:
get:
operationId: getUserById
parameters:
- name: userId
in: path
required: true
schema:
type: string
responses:
'200':
description: A single user
Naming conventions matter. Pick one and hold it across the whole spec:
listUsers, createUser, getUserById, updateUser, deleteUser.operationId per operation across the entire document — the OpenAPI spec requires uniqueness, and the plugin enforces it.If you are retrofitting an existing spec, generate the IDs from ${method}${PascalCasePath} and then review. Auto-generated IDs read fine for CRUD but usually need cleanup for search endpoints, batch operations, and anything with query-parameter variants.
The service object is a plain JavaScript object whose method names match the operationIds in your spec. Each method receives the Fastify request and reply and returns the response body.
Create service.js:
export class Service {
async listUsers(request, reply) {
return [
{ id: '1', name: 'Ada' },
{ id: '2', name: 'Grace' }
];
}
async createUser(request, reply) {
const user = request.body;
reply.code(201);
return { id: '3', ...user };
}
async getUserById(request, reply) {
const { userId } = request.params;
return { id: userId, name: 'Ada' };
}
}
Two things worth calling out. First, return the payload — do not call reply.send() unless you need to. Fastify handles serialisation, and returning the value keeps the handlers testable in isolation. Second, use a class or a factory function. It gives you a clean place to inject a database client, a logger, or a feature flag service without turning every handler into a closure.
Wire the spec and the service object into Fastify. Create server.js:
import Fastify from 'fastify';
import openapiGlue from 'fastify-openapi-glue';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { Service } from './service.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const fastify = Fastify({ logger: true });
fastify.register(openapiGlue, {
specification: join(__dirname, 'openapi.yaml'),
serviceHandlers: new Service(),
prefix: 'v1'
});
fastify.listen({ port: 3000 }, (err) => {
if (err) {
fastify.log.error(err);
process.exit(1);
}
});
Start the server:
node server.js
Expected output: Fastify logs one line per registered route, one per operationId in the spec. If a route is missing, the operationId in the spec does not match a method name on the service object — check for typos and casing.
Silent drift is the failure mode that catches teams out. Someone adds an operation to the spec, forgets the handler, and the request 404s in production. Add a startup check that compares the spec against the service object before the server accepts traffic.
Create contract-check.js:
import { readFileSync } from 'node:fs';
import { parse } from 'yaml';
import { Service } from './service.js';
const spec = parse(readFileSync('./openapi.yaml', 'utf8'));
const service = new Service();
const operationIds = [];
for (const path of Object.values(spec.paths)) {
for (const [method, op] of Object.entries(path)) {
if (['get', 'post', 'put', 'patch', 'delete'].includes(method)) {
if (!op.operationId) {
throw new Error(`Missing operationId on ${method.toUpperCase()} ${path}`);
}
operationIds.push(op.operationId);
}
}
}
const missing = operationIds.filter(
(id) => typeof service[id] !== 'function'
);
if (missing.length) {
throw new Error(`Missing handlers for: ${missing.join(', ')}`);
}
console.log(`All ${operationIds.length} operations have handlers.`);
Run it in CI before tests. This is the single highest-leverage check in the whole setup. It catches the class of bug that would otherwise ship silently — an added endpoint without a handler, a renamed handler that no longer matches, a typo in a operationId that regressed during a merge.
The same principle applies more broadly to any OpenAPI spec drift between contract and implementation. Catch it at build time, not at 3am.
fastify-openapi-glue reads security blocks from your spec but does not implement the schemes for you. You wire the actual auth using Fastify hooks or a securityHandlers object passed alongside serviceHandlers.
Add a bearer scheme to the spec:
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
security:
- bearerAuth: []
Then extend the plugin registration:
fastify.register(openapiGlue, {
specification: join(__dirname, 'openapi.yaml'),
serviceHandlers: new Service(),
securityHandlers: {
async bearerAuth(request, reply, params) {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new Error('Missing token');
request.user = await verifyToken(token);
}
}
});
Each method on securityHandlers maps to a scheme name in components.securitySchemes. The plugin invokes it before the corresponding operation runs.
operationId to be unique across the document, but a JSON or YAML parser won't catch a duplicate on its own. The plugin will refuse to start when it finds one — add a lint rule (Spectral's operation-operationId-unique) to catch it earlier. Spectral ships this rule out of the box.operationId on new endpoints. Enforce it in your spec linter, not at code review.return and reply.send() in the same handler.:userId; OpenAPI uses {userId}. The plugin translates them, but if you write route handlers by hand elsewhere the mismatch will bite.Once the loop is tight — spec change, handler added, CI green — the plugin fades into the background and the OpenAPI document becomes the actual contract, not just documentation someone hopes is current.
Stay up to date on the ever changing agentic landscape.