REST, OpenAPI, GraphQL
REST and GraphQL are two doors onto the same operations. Turn them on in the config; your handlers do not change, and every rule they hold — validation, refusals, who may call — is the same through either.
Turn them on
npm install @fougere/adapter-rest @fougere/adapter-graphql @pothos/core graphql
pnpm add @fougere/adapter-rest @fougere/adapter-graphql @pothos/core graphql
bun add @fougere/adapter-rest @fougere/adapter-graphql @pothos/core graphql
yarn add @fougere/adapter-rest @fougere/adapter-graphql @pothos/core graphql
@pothos/core and graphql are peers of the GraphQL adapter: your app holds the one copy the schema is
built with, which is what lets you add types of your own to the same builder.
// fougere.config.ts
import '@fougere/adapter-rest';
import '@fougere/adapter-graphql';
export default defineFougere({
protocols: { rest: {}, graphql: {} },
});
The import makes a protocol available; naming it under protocols: serves it.
curl http://localhost:3000/api/blog/posts
curl -X POST http://localhost:3000/graphql \
-H 'content-type: application/json' \
-d '{"query":"{ posts { items { title } } }"}'
A list is a page on both: the rows are in items.
REST: the URL
A URL has three parts, each derived, each changed by the one who owns it:
| Part | Read from | To change it |
|---|---|---|
/api — where the app mounts REST | the convention | protocols: { rest: { prefix: '/v1' } } in fougere.config.ts |
/blog — the frond | the frond's name | protocols: { rest: { path: '/journal' } } in its frond.config.ts |
/posts/{id}/publish — the operation | the class and the signature | operations: { 'post.publish': { rest: { path, method, status } } } in its frond.config.ts |
The address is made plural (post → posts, category → categories), so checkout.pay answers
POST /api/shop/checkouts/pay:
| Route | Operation |
|---|---|
GET /api/blog/posts | post.list |
POST /api/blog/posts | post.create |
GET /api/blog/posts/{id} | post.findById |
PATCH /api/blog/posts/{id} | post.update |
DELETE /api/blog/posts/{id} | post.delete |
GET /api/blog/posts/by-slug | post.findBySlug — a named operation wins over {id} |
POST /api/blog/posts/{id}/publish | post.publish(id: Post['id']) |
A list reads its page and its filters off the query string, as text the field's own shape
turns back into a value: GET /api/blog/posts?limit=20&offset=40&count=true pages,
?status=published keeps the rows whose status is that value, and ?views=3 compares a number.
A key that is neither an option nor a declared field is refused with 400, naming the options.
The path is read off the signature, never off the name. A parameter typed by the entity's key —
id: Post['id'] — points at one row, so it comes before the operation: /posts/{id}/publish.
Another required value comes after it — byTag(tag: string) answers /posts/by-tag/{tag} — and an
optional one is read from the query string. Typed as Post['id'], the id also obeys the rules the
entity declares for it.
The method is read off the contract too:
| The operation | Method |
|---|---|
| reads (a query) | GET |
its name starts with delete or remove | DELETE |
points at one row and takes Partial<Post> | PATCH |
points at one row and takes only fields of it — retitle(id: Post['id'], title: Post['title']) | PATCH, the fields in the body |
points at one row and takes a whole Post | PUT |
| anything else | POST |
The path points at the row and the body carries what changes: retitle answers
PATCH /api/blog/posts/{id}/retitle with { "title": "…" }. A parameter that is not a field of the
row — retitle(id, title, reason: string) — makes it an action, so POST. A path called with
another method answers 405 with an Allow list; it never falls through to another operation.
npx fougere explain lists
the method and path of every operation, with its GraphQL field beside it.
The status is read off the contract, never off the name: 201 for a POST on the collection that
answers one row, 204 for an operation that answers nothing or a DELETE that answers true, 404
for a row that is not there, 200 otherwise. protocols.rest.status wins over all of them. A page
answers { items, total, hasMore, endCursor }.
Everything else is the same as any call: the same validation and unknown-key refusal, the same
collectors, a body that is not JSON refused with 415, and errors at the HTTP status of their code
(Error codes), the whole value in data.
A frond you cannot edit
fougere.config.ts may name the route of an operation for a frond a package brings — betterAuth():
protocols: { rest: { overrides: { 'session.get': { path: '/me' } } } },
For a frond of your project, the boot refuses this and gives the line to write in that frond's
frond.config.ts. A host that routes by file — SvelteKit, TanStack Start, React Router, Next — mounts
REST where its route file sits; when that file is not under the prefix, the answer is a 404 that
says so.
A process serves the REST of the fronds it hosts: fougere serve blog answers /api/blog/…, and a
process that calls blog elsewhere does not serve its routes.
Around a request — a link in front of Rest
A link in front of Dispatcher stands around a call,
whichever door brought it. A link in front of Rest stands around a REST request, in REST's own
terms: it reads the headers before the body is read, refuses by throwing, and adds to the answer.
// fronds/blog/services/UploadLimit.ts
import { ErrorCode, FougereError } from '@fougere/core';
import type { ResponseResult, Rest, RestRequest } from '@fougere/adapter-rest';
export default class UploadLimit implements Partial<Rest> {
constructor(private inner: Rest) {}
async answer(request: RestRequest): Promise<ResponseResult> {
if (Number(request.headers.get('content-length')) > 1000) {
throw new FougereError({ code: ErrorCode.PAYLOAD_TOO_LARGE, message: 'Posts stop at 1000 bytes.' });
}
return this.inner.answer(request);
}
}
Rest stands around every route of the frond. Rest<PostHandler> stands around that handler's routes
only, one method per operation it names, and may answer in the operation's place — it extends the base,
whose answer hands a request to the method named after its op, and to inner otherwise:
export default class PostRest extends Rest<PostHandler> {
constructor(inner: Rest<PostHandler>) { super(inner); }
async findById(request: RestRequest): Promise<ResponseResult | RestResponse<301, { location: string }>> {
if (request.params.id === 'old-slug') return { status: 301, headers: { location: '/api/blog/posts/p1' }, data: null };
return this.inner!.answer(request);
}
}
A method naming no operation of the handler refuses the boot. What it may answer besides the
operation — the 301 and its location above — is read off its return type by the scan and
published on the route, which is what OpenAPI
lists; a frond() written by hand states it as { ctor: PostRest, deps: ['Rest<PostHandler>'], implements: ['Rest<PostHandler>'], answers: { findById: [{ status: 301, headers: ['location'] }] } }.
At a door, a refusal IS an answer: what a link throws leaves it as REST's answer, so a link standing
outside it still decorates it — headers on a 413 as on a 200, with no catch to write. Two of one
Frond are ordered by ports: { Rest: ['Powered', 'UploadLimit'] } in frond.config.ts. GraphQL runs
none: a document spans several addresses.
OpenAPI — published on request
protocols: { rest: { openapi: {} } } publishes an OpenAPI 3.1 document at /api/openapi.json.
Without the line nothing is published: the document names every op and what each refuses at once.
A page reading it is a second decision, since it loads code from a CDN: openapi: { ui: true }
serves Swagger UI at /api/docs, and ui: 'scalar' serves
Scalar instead, its telemetry turned off. Each file is pinned to a version and to its hash
(integrity), so a browser refuses a file the CDN changed rather than running it.
Nothing is declared for it. It is read off the routes the process serves:
- each route — its path, its method,
operationIdas<address>.<op>, the method's doc sentence as its description, the parameters its signature puts in the path or the query; - its body — the op's input view, or the fields of the row a change carries;
- its answers — the status the door answers with and what it answers, a page as
{ items, total, … }; what a link at REST's door may answer in the op's place, with its headers; and what the op can refuse, at the status a refusal is answered with — the codes the scan found in its code beside the ones the framework implies, each status naming its own codes.
A schema is the card's own descriptor, since a card IS JSON Schema: an entity
is written once under components, and a view cut from it is named by where it is used
(PostUpdateInput). An op an announcement calls has no route, so it is not in the document.
GraphQL
@fougere/adapter-graphql builds Pothos types, inputs and operations from the same entities. Under
Nuxt, protocols: { graphql: {} } mounts it on /graphql; anywhere else, hand the schema to the
GraphQL server you use:
import SchemaBuilder from '@pothos/core';
import { graphqlDoor, registerAll } from '@fougere/adapter-graphql';
const builder = new SchemaBuilder({});
builder.queryType({});
builder.mutationType({});
registerAll(builder, app); // every address, every operation, every relation
const door = graphqlDoor(builder.toSchema()); // or hand the schema to Yoga, Apollo…
One field per operation:
| Operation | Field |
|---|---|
list | posts |
findById | post |
create, update, delete | createPost, updatePost, deletePost |
| any other | its own name — publish |
A type is named after its frond first — BlogPost, CreateBlogPostInput — the way a REST URL carries
/blog/. Two fronds may each declare an Author, and each is its own type: BlogAuthor,
ShopAuthor. ref and many connect the types; computed fields are
resolvers. Every field calls the same API as REST, so validation and collectors hold; a resolver you
write against the storage directly would hold none of them. registerType and registerOperations
add what cannot be derived.
registerAll(builder, app, { surface: 'graphql' }); // honour frond.config.ts surfaces
registerAll(builder, app, { filter: (address) => address !== 'auditLog' });
GraphQL runs no link in front of Rest: a document spans several addresses.
schema-ecommerce runs it under
Apollo Server.
demos/schema-ecommerce10 files/**
* Adapter GraphQL — schema Pothos généré depuis les entités fougere
*/
import SchemaBuilder from '@pothos/core';
import { registerType, registerInput, registerOperations } from '@fougere/adapter-graphql/pothos';
import { categoryStorage, productStorage, customerStorage, orderLineStorage, orderStorage } from './db.js';
import {
Category, Product, Customer, OrderLine, Order,
CreateProduct, UpdateProduct,
} from './entities.js';
const builder = new SchemaBuilder<{}>({});
// ─── Types ─────────────────────────────────────────
const CategoryType = registerType(builder, {
name: 'Category',
entity: Category,
});
const ProductType = registerType(builder, {
name: 'Product',
entity: Product,
exclude: ['categoryId'],
relations: {
category: {
type: CategoryType,
resolve: (p: any) => categoryStorage.findById(p.categoryId),
},
},
});
const CustomerType = registerType(builder, {
name: 'Customer',
entity: Customer,
exclude: ['email'],
});
const OrderLineType = registerType(builder, {
name: 'OrderLine',
entity: OrderLine,
exclude: ['productId'],
relations: {
product: {
type: ProductType,
resolve: (p: any) => productStorage.findById(p.productId),
},
},
});
const OrderType = registerType(builder, {
name: 'Order',
entity: Order,
exclude: ['customerId'],
relations: {
customer: {
type: CustomerType,
resolve: (p: any) => customerStorage.findById(p.customerId),
},
lines: {
type: OrderLineType,
list: true,
resolve: (p: any) => orderLineStorage.findAllBy({ orderId: p.id }),
},
},
});
// ─── Inputs ────────────────────────────────────────
const CreateProductInput = registerInput(builder, { name: 'CreateProductInput', schema: CreateProduct });
const UpdateProductInput = registerInput(builder, { name: 'UpdateProductInput', schema: UpdateProduct });
const CreateOrderLineInput = builder.inputType('CreateOrderLineInput', {
fields: (f) => ({
productId: f.string({ required: true }),
quantity: f.int({ required: true }),
}),
});
const CreateOrderInput = builder.inputType('CreateOrderInput', {
fields: (f) => ({
customerId: f.string({ required: true }),
note: f.string(),
lines: f.field({ type: [CreateOrderLineInput], required: true }),
}),
});
// ─── Read-only operations via registerOperations ──
builder.queryType({});
/** Helper: create a read-only operations map (list + findById). */
function readOnlyOps() {
return new Map<string, any>([
['list', {
kind: 'query',
signature: {
name: 'list',
params: [{ name: 'options', type: { raw: 'ListOptions', name: 'ListOptions' }, optional: true }],
returnType: { raw: 'ListResult', name: 'ListResult' },
},
}],
['findById', {
kind: 'query',
signature: {
name: 'findById',
params: [{ name: 'id', type: { raw: 'string', name: 'string' } }],
returnType: { raw: 'any', name: 'any', nullable: true },
},
}],
]);
}
registerOperations(builder, {
name: 'Product',
type: ProductType,
facade: {
list: (ctx: any) => productStorage.list(ctx.body),
findById: (ctx: any) => productStorage.findById(ctx.params.id),
},
operations: readOnlyOps(),
});
registerOperations(builder, {
name: 'Category',
type: CategoryType,
facade: {
list: (ctx: any) => categoryStorage.list(ctx.body),
findById: (ctx: any) => categoryStorage.findById(ctx.params.id),
},
operations: readOnlyOps(),
});
registerOperations(builder, {
name: 'Customer',
type: CustomerType,
facade: {
list: (ctx: any) => customerStorage.list(ctx.body),
findById: (ctx: any) => customerStorage.findById(ctx.params.id),
},
operations: readOnlyOps(),
});
registerOperations(builder, {
name: 'Order',
type: OrderType,
facade: {
list: (ctx: any) => orderStorage.list(ctx.body),
findById: (ctx: any) => orderStorage.findById(ctx.params.id),
},
operations: readOnlyOps(),
});
// ─── Mutations ─────────────────────────────────────
builder.mutationType({});
(builder as any).mutationFields((f: any) => ({
createProduct: f.field({
type: ProductType,
args: { input: f.arg({ type: CreateProductInput, required: true }) },
resolve: async (_: any, { input }: any) => {
const v = CreateProduct.validate(input);
if (!v.success) throw new Error(v.errors.map((e: any) => `${e.path}: ${e.message}`).join(', '));
// active/createdAt are realised by the storage (SQL default, auto timestamp) —
// the caller only supplies what it owns.
return productStorage.create(input);
},
}),
updateProduct: f.field({
type: ProductType,
nullable: true,
args: {
id: f.arg.string({ required: true }),
input: f.arg({ type: UpdateProductInput, required: true }),
},
resolve: async (_: any, { id, input }: any) => {
const v = UpdateProduct.validate(input);
if (!v.success) throw new Error(v.errors.map((e: any) => `${e.path}: ${e.message}`).join(', '));
const updates: Record<string, unknown> = {};
for (const [k, val] of Object.entries(input)) {
if (val != null) updates[k] = val;
}
// An empty patch has no SET clause to run — just hand back the current row.
if (Object.keys(updates).length === 0) return (await productStorage.findById(id)) ?? null;
return productStorage.update(id, updates);
},
}),
createOrder: f.field({
type: OrderType,
args: { input: f.arg({ type: CreateOrderInput, required: true }) },
resolve: async (_: any, { input }: any) => {
let total = 0;
const lines: { productId: string; quantity: number; unitPrice: number }[] = [];
for (const line of input.lines) {
const product = await productStorage.findById(line.productId);
if (!product) throw new Error(`Product ${line.productId} not found`);
total += (product as any).price * line.quantity;
lines.push({ productId: line.productId, quantity: line.quantity, unitPrice: (product as any).price });
}
const order = await orderStorage.create({
customerId: input.customerId,
status: 'pending',
total,
note: input.note ?? null,
});
for (const line of lines) {
await orderLineStorage.create({ ...line, orderId: order.id });
}
return order;
},
}),
}));
export const graphqlSchema = builder.toSchema();
Any engine
Fougere opens no socket. An app answers a Web Request: answer(app, request)
(@fougere/app/answer) finds the door from the path — the call envelope, REST, GraphQL — and answers
undefined when none is the request's, so your own routes keep answering.
| What you bring | What plugs into it |
|---|---|
Hono, a Worker, anything speaking Request | hono.all('*', async (c) => (await answer(app, c.req.raw)) ?? c.notFound()) |
| Express | app.use(fougere()) from @fougere/app/express |
| your GraphQL server (Apollo, Yoga…) | registerAll(builder, app) returns a Pothos schema |
| Nuxt / Nitro | the module mounts everything |
| nothing at all | fougere serve, and fougere call from a shell |
| your own code | invoke — a call is a value, not a request |
Changing HTTP server moves your routes, not your handlers, and a test calls an operation without
opening a port. What travels to a browser is the contract — the entities, their validation and the
call format (@fougere/core/contract, which needs no Node built-in); the validator refusing a form
in the browser is the same one refusing the body on the server. Hosts in detail: Pick a host.