feat: writes, procedures, Fastify and Express adapters, documentation

This commit is contained in:
2026-11-02 01:25:49 +01:00
parent 4b276f21e3
commit 6801f257fb
35 changed files with 3109 additions and 182 deletions
+49
View File
@@ -0,0 +1,49 @@
{
"name": "@crudy/restloom-fastify",
"version": "0.1.0",
"type": "module",
"description": "Fastify adapter for restloom",
"keywords": [
"restloom",
"fastify",
"rest",
"api"
],
"author": "Marc Villain",
"homepage": "https://restloom.crudy.fr",
"repository": {
"type": "git",
"url": "git+https://github.com/crudylabs/restloom.git",
"directory": "packages/fastify"
},
"license": "Apache-2.0",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": [
"dist",
"LICENSE",
"README.md"
],
"engines": {
"node": ">=20"
},
"scripts": {
"build": "tsc -p .",
"prepack": "npm run build"
},
"dependencies": {
"@crudy/restloom": "^0.1.0"
},
"peerDependencies": {
"fastify": "^5.0.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "^5.9.0",
"fastify": "^5.0.0"
}
}
+32
View File
@@ -0,0 +1,32 @@
import type { Rest } from '@crudy/restloom';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
export interface RestloomFastifyOptions<C> {
/** Builds the context hooks receive, e.g. the authenticated user. */
context?(req: FastifyRequest): C | Promise<C>;
}
/**
* A Fastify plugin answering every path under its prefix with restloom.
* Register it with a prefix: `app.register(restloom(rest), { prefix: '/api' })`.
*/
export function restloom<C>(rest: Rest<C>, options: RestloomFastifyOptions<C> = {}) {
return async function plugin(app: FastifyInstance): Promise<void> {
const handler = async (req: FastifyRequest, reply: FastifyReply) => {
const url = new URL(req.raw.url ?? '/', 'http://localhost');
const prefix = app.prefix ?? '';
const headers = Object.fromEntries(Object.entries(req.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(', ') : v]));
const res = await rest.handle({
method: req.method,
path: url.pathname.slice(prefix.length) || '/',
query: url.searchParams,
headers,
body: req.body,
context: (await options.context?.(req)) as C,
});
reply.code(res.status).headers(res.headers);
return res.body === undefined ? reply.send() : reply.send(res.body);
};
app.route({ method: ['GET', 'POST', 'PATCH', 'DELETE'], url: '/*', handler });
};
}
+23
View File
@@ -0,0 +1,23 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { createREST } from '@crudy/restloom';
import { restloom } from '../dist/index.js';
import { fakeDialect } from '../../core/test/fake-dialect.mjs';
const require = createRequire(import.meta.url);
const Fastify = require('fastify');
test('serves restloom under its prefix with the request context', async () => {
let seen;
const rest = createREST({ db: fakeDialect(), expose: { schemas: ['app'] }, hooks: { authorize(req) { seen = req.context; } } });
const app = Fastify();
await app.register(restloom(rest, { context: (req) => ({ user: req.headers['x-user'] }) }), { prefix: '/api' });
const res = await app.inject({ method: 'GET', url: '/api/items?select=id&count=exact', headers: { 'x-user': 'ada' } });
assert.equal(res.statusCode, 200);
assert.deepEqual(res.json(), [{ id: 1, name: 'one' }]);
assert.equal(res.headers['content-range'], '0-0/1');
assert.deepEqual(seen, { user: 'ada' });
assert.equal((await app.inject({ method: 'GET', url: '/api/nope' })).statusCode, 404);
await app.close();
});
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src"]
}