Skip to content

Latest commit

 

History

History
326 lines (243 loc) · 9.61 KB

File metadata and controls

326 lines (243 loc) · 9.61 KB

H3 Adapter

Ampulla's H3 adapter turns your DI-managed classes into H3 routes. Import from ampulla/h3.

import { Controller, Get, Post, Extract, UseMiddleware, registerControllers } from "ampulla/h3";
import { param, query, json } from "ampulla/h3";

The pattern at a glance

import { H3 } from "h3";
import { Container } from "ampulla";
import { registerControllers } from "ampulla/h3";

import { AppModule } from "./app.module.js";

const app = new H3();
const container = await Container.create(AppModule);

registerControllers(app, container);

export default app;

registerControllers iterates the container, finds every class decorated with @Controller, and registers its routes on the H3 app. Providers without @Controller metadata are silently ignored.

@Controller and HTTP method decorators

@Controller(prefix) marks a class as a controller and sets the route prefix. The prefix defaults to "" (empty string, routes at root).

Each route handler is a method decorated with one of the HTTP verb decorators:

import { Injectable } from "ampulla";
import { Controller, Get, Post, Delete } from "ampulla/h3";
import type { H3Event } from "h3";

@Controller("users")
@Injectable()
class UserController {
  @Get()
  list(event: H3Event) {
    return { users: [] };
  }

  @Get(":id")
  getOne(event: H3Event) {
    return { id: event.context.params?.id };
  }

  @Post()
  create(event: H3Event) {
    return { created: true };
  }

  @Delete(":id")
  remove(event: H3Event) {
    return { deleted: true };
  }
}

Path joining rules:

  • @Controller("users") + @Get(":id")/users/:id
  • Leading slashes on method paths are tolerated: @Get("/search") and @Get("search") both work
  • An empty method path registers at the controller prefix: @Controller("users") + @Get()/users
  • Both empty → root: @Controller() + @Get()/

Why both @Controller and @Injectable? They are intentionally separate because they do different things. @Injectable is a DI primitive: it tells the container what dependencies to inject into the constructor. @Controller is a routing primitive: it tells registerControllers what URL prefix this class owns. Neither implies the other. See Core Concepts for the full rationale.

Decorator order matters. Class decorators run bottom-to-top in the modern JavaScript decorator spec. @Controller must appear above @Injectable in source so that it runs after — it needs to scan the prototype for routes that the HTTP verb decorators have already registered.

@Controller("users")   // runs second — scans prototype for routes
@Injectable()          // runs first — stores DI metadata
class UserController { ... }

Available verbs: @Get, @Post, @Put, @Patch, @Delete.

@Extract — handler ergonomics

By default, route handlers receive the raw H3Event. @Extract lets you declare what the handler actually needs, and the adapter extracts it before calling the handler.

import { Get, Extract, param, query } from "ampulla/h3";

@Get(":id")
@Extract(param("id"))
async getUser(id: string | undefined) {
  // id is already extracted — no H3Event needed
}

Pass one or more extractors — each becomes a positional argument to the handler, in order:

// Single extractor — handler receives the value directly
@Extract(query("page"))
handler(page: string | undefined) {}

// Multiple extractors — splatted as positional arguments
@Extract(param("id"), query("page"), json<CreateUserDto>())
handler(id: string | undefined, page: string | undefined, body: CreateUserDto) {}

To combine multiple values into a single object argument instead, use one function extractor that builds the object itself:

@Extract(e => ({ id: e.context.params?.id, page: new URL(e.req.url).searchParams.get("page") }))
handler(params: { id: string | undefined; page: string | null }) {}

Built-in extractors

Extractor What it returns
param(name) URL path parameter (:name) from event.context.params
query(name) Single query-string value
queries(name) All values for a repeated query param
header(name) Request header value
json<T>() Parsed JSON body
text() Plain text body
formData() FormData object
bytes() Request body as Uint8Array
ctxGet<T>(key) event.context[key] — middleware-set value
ctx() The raw H3Event (useful alongside other extractors to access it directly)

Transforming and validating

Extractors have two chainable methods:

// .pipe — transform the extracted value
@Extract(query("page").pipe(s => parseInt(s ?? "1", 10)))
list(page: number) {}

// .valid — validate with any Standard Schema–compatible library (Zod, Valibot, ArkType, etc.)
import { z } from "zod";

@Extract(json<unknown>().valid(z.object({ name: z.string() })))
create(body: { name: string }) {}

When .valid() fails, it throws a ValidationError that becomes the .cause of an ExtractionError — add H3 error handling to convert it into a 400 response.

@UseMiddleware

Attach H3 middleware to a controller class or a single route method.

import { UseMiddleware } from "ampulla/h3";
import type { H3Middleware } from "h3";

const authMiddleware: H3Middleware = async (event, next) => {
  const token = event.req.headers.get("Authorization");
  if (!token) return Response.json({ error: "Unauthorized" }, { status: 401 });
  event.context.userId = verifyToken(token);
  return next();
};

// Class-level: runs before every route in this controller
@UseMiddleware(authMiddleware)
@Controller("admin")
@Injectable()
class AdminController { ... }

// Method-level: runs only before this route
@UseMiddleware(rateLimitMiddleware)
@Get("resource")
getResource(event: H3Event) { ... }

Multiple @UseMiddleware decorators stack top-down — the topmost runs first. Class-level middleware runs before method-level middleware.

Injectable middleware classes

For middleware that itself needs dependencies from the container, implement the MiddlewareClass interface and register it as a provider:

import type { MiddlewareClass } from "ampulla/h3";
import { Injectable } from "ampulla";

@Injectable(AUTH_SERVICE)
class AuthMiddleware implements MiddlewareClass {
  constructor(private readonly auth: AuthService) {}

  async use(event: H3Event, next: () => Promise<void>) {
    const token = event.req.headers.get("Authorization");
    const user = await this.auth.verify(token);
    if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
    event.context.user = user;
    return next();
  }
}

@Module({
  providers: [AuthService, AuthMiddleware],
})
class AuthModule {}

Then reference the class (or its injection token) in @UseMiddleware:

@UseMiddleware(AuthMiddleware)
@Controller("admin")
@Injectable()
class AdminController { ... }

registerControllers detects that AuthMiddleware is a registered provider and resolves it from the container instead of using it as a plain function.

@Header

Sets a fixed response header on a route. A thin wrapper around @UseMiddleware:

import { Header, Get } from "ampulla/h3";

@Header("Cache-Control", "public, max-age=3600")
@Header("X-Content-Type-Options", "nosniff")
@Get(":id")
getOne(event: H3Event) { ... }

Multiple @Header decorators stack like any other middleware — all headers are set before the handler runs.

Full example

import { H3 } from "h3";
import { Injectable, Module, injection, useValue } from "ampulla";
import { Controller, Get, Post, Delete, Extract, UseMiddleware, Header } from "ampulla/h3";
import { param, json, ctxGet } from "ampulla/h3";
import { Container, registerControllers } from "ampulla";
import { z } from "zod";

// --- tokens ---
const DB = injection<Database>("DB");

// --- services ---
@Injectable(DB)
class UserService {
  constructor(private readonly db: Database) {}

  findAll() { return this.db.findAll("users"); }
  findOne(id: string) { return this.db.findOne("users", id); }
  create(data: { name: string }) { return this.db.insert("users", data); }
  delete(id: string) { return this.db.delete("users", id); }
}

// --- middleware ---
const logger: H3Middleware = async (event, next) => {
  console.log(`${event.req.method} ${event.req.url}`);
  return next();
};

// --- controller ---
@UseMiddleware(logger)
@Controller("users")
@Injectable(UserService)
class UserController {
  constructor(private readonly users: UserService) {}

  @Get()
  list(event: H3Event) {
    return this.users.findAll();
  }

  @Header("Cache-Control", "no-store")
  @Get(":id")
  @Extract(param("id"))
  getOne(id: string | undefined) {
    return this.users.findOne(id ?? "");
  }

  @Post()
  @Extract(json<unknown>().valid(z.object({ name: z.string() })))
  async create(body: { name: string }) {
    return this.users.create(body);
  }

  @Delete(":id")
  @Extract(param("id"))
  remove(id: string | undefined) {
    return this.users.delete(id ?? "");
  }
}

// --- module ---
@Module({
  providers: [
    useValue(DB, new InMemoryDatabase()),
    UserService,
    UserController,
  ],
})
class AppModule {}

// --- bootstrap ---
const app = new H3();
const container = await Container.create(AppModule);
registerControllers(app, container);

export default app;

Error handling

Error When it's thrown
ExtractionError A @Extract extractor threw. Inspect .cause for the underlying error.
ValidationError A .valid() schema check failed. Surfaces as .cause on ExtractionError.
InvalidHandlerError A route's handler name doesn't resolve to a function on the controller instance.