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

@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:

PartRead fromTo change it
/api — where the app mounts RESTthe conventionprotocols: { rest: { prefix: '/v1' } } in fougere.config.ts
/blog — the frondthe frond's nameprotocols: { rest: { path: '/journal' } } in its frond.config.ts
/posts/{id}/publish — the operationthe class and the signatureoperations: { '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:

RouteOperation
GET /api/blog/postspost.list
POST /api/blog/postspost.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-slugpost.findBySlug — a named operation wins over {id}
POST /api/blog/posts/{id}/publishpost.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 operationMethod
reads (a query)GET
its name starts with delete or removeDELETE
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 PostPUT
anything elsePOST

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.

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, operationId as <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:

OperationField
listposts
findByIdpost
create, update, deletecreatePost, updatePost, deletePost
any otherits 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
Local only
src/adapter-graphql.ts
/**
 * 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 bringWhat plugs into it
Hono, a Worker, anything speaking Requesthono.all('*', async (c) => (await answer(app, c.req.raw)) ?? c.notFound())
Expressapp.use(fougere()) from @fougere/app/express
your GraphQL server (Apollo, Yoga…)registerAll(builder, app) returns a Pothos schema
Nuxt / Nitrothe module mounts everything
nothing at allfougere serve, and fougere call from a shell
your own codeinvoke — 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.

Built with Fougere and Nuxt, hosted on GitHub Pages.