Skip to content

Latest commit

 

History

History
460 lines (362 loc) · 15.3 KB

4.parameters.md

File metadata and controls

460 lines (362 loc) · 15.3 KB
title Read and transform request data
description Reference for the parameter decorators, parameter providers and modifiers, and the parameter processing chain of the AntelopeJS API interface.
mode reference

A parameter decorator supplies a handler argument from the request. Each argument has one source. A transformation or validation step changes the raw value before the handler receives it.

For a short example, see @Parameter. The custom provider and modifier functions are for reusable integrations. Reading request data does not need them.

Parameter sources

A parameter comes from one of these sources:

  • A route parameter, a segment of the URL path marked with :paramName.
  • A query parameter, a value from the URL query string.
  • An HTTP header value.
  • The request body.
  • The complete request context object.

Parameter decorators

@Parameter

@Parameter(name, source = "param") extracts a single value from the route parameters, the query string, or the headers.

import { Controller, Get, Parameter } from "@antelopejs/interface-api";

class UsersController extends Controller("/users") {
  @Get(":id")
  async getUser(@Parameter("id") id: string) {
    return { id, name: "Example User" };
  }

  @Get("")
  async listUsers(@Parameter("page", "query") page: string | true | undefined) {
    // For a request to /users?page=2, page is "2"
    return { users: ["user1", "user2"], page };
  }

  @Get(":userId/posts/:postId")
  async getUserPost(
    @Parameter("userId") userId: string,
    @Parameter("postId") postId: string,
  ) {
    return { userId, postId };
  }
}

The second argument sets the source:

Source Description Example
"param" Route parameters (default) /users/:id extracts id
"query" URL query parameters /users?page=2 extracts page
"header" HTTP request headers Authorization extracts that header's value

A missing value produces undefined. For a query parameter, ?page and ?page= produce true, not an empty string, and ?page=2 produces "2". The type of a "query" parameter or property must admit string | true | undefined: the compiler refuses a narrower type such as string, and accepts unknown. The check reads the declared type, so a "query" parameter with a stacked @Transform must still be typed to admit the raw value; convert it in the handler, or build the decorator from a custom provider. Private and protected members are not checked. A repeated query parameter produces its first value. Header names are case-insensitive. Handle these cases before you convert a value to a number or use it as a string.

@MultiParameter

@MultiParameter(name, source = "query") extracts every value of a parameter as an array, for a query parameter or a header that appears more than once.

import { Controller, Get, MultiParameter } from "@antelopejs/interface-api";

class ProductsController extends Controller("/products") {
  @Get("search")
  async searchProducts(@MultiParameter("tag") tags: string[]) {
    // For /products/search?tag=js&tag=api, tags is ["js", "api"]
    return { tags, results: ["product1", "product2"] };
  }
}

The source is "query" (the default) or "header".

A missing value produces []. Query values keep empty strings, so ?tag=&tag=api produces ["", "api"]. Header values are the values Node.js parses. The decorator does not split comma-separated strings.

@RawBody

@RawBody(limit?) provides the raw HTTP request body as a Buffer. The limit is in bytes and defaults to DEFAULT_REQUEST_BODY_LIMIT.

import { Controller, Post, RawBody } from "@antelopejs/interface-api";

class PostsController extends Controller("/posts") {
  @Post()
  async createPost(@RawBody() postData: Buffer) {
    const parsed = JSON.parse(postData.toString());
    return { id: "new-post-id", ...parsed };
  }
}

@BodyStream

@BodyStream() provides the request body as an AsyncIterable<Uint8Array>. The body is read as the handler iterates it and is never buffered whole. No body limit applies. See read request bodies.

import { BodyStream, Controller, Put } from "@antelopejs/interface-api";

class UploadsController extends Controller("/uploads") {
  @Put()
  async upload(@BodyStream() body: AsyncIterable<Uint8Array>) {
    let bytes = 0;
    for await (const chunk of body) bytes += chunk.byteLength;
    return { bytes };
  }
}

@JSONBody

@JSONBody(limit?) parses the body as JSON, which produces an object, an array, a primitive, or null. An empty body produces undefined. It does not validate a schema. Treat an external body as unknown until you validate it. Before you accept uploads, read body limits.

import { Controller, Post, JSONBody } from "@antelopejs/interface-api";

class UsersController extends Controller("/users") {
  @Post()
  async createUser(@JSONBody() userData: { name: string; email: string }) {
    return { id: "new-user-id", ...userData };
  }
}

@Context

@Context() provides the complete RequestContext object.

import {
  Controller,
  Get,
  Context,
  RequestContext,
} from "@antelopejs/interface-api";

class InfoController extends Controller("/info") {
  @Get()
  handleRequest(@Context() ctx: RequestContext) {
    const clientIP = ctx.rawRequest.socket.remoteAddress;
    const requestUrl = ctx.url.toString();
    return { ip: clientIP, url: requestUrl };
  }
}

@Result

@Result() provides the HTTPResult response object. A postfix handler uses it to change the response. It applies to handler parameters only, not to class properties.

import {
  Controller,
  Get,
  Postfix,
  Result,
  HTTPResult,
} from "@antelopejs/interface-api";

class ResponseController extends Controller("/api") {
  @Get("data")
  async getData() {
    return { data: "example" };
  }

  @Postfix("get", "data")
  async addMetadata(@Result() result: HTTPResult) {
    result.addHeader("X-Timestamp", new Date().toISOString());
  }
}

@WriteStream

@WriteStream(type?) puts the response in stream mode and provides its PassThrough stream, for streamed responses or server-sent events. The content type defaults to text/plain. It applies to handler parameters only, not to class properties.

import {
  Controller,
  Get,
  WriteStream,
  Context,
  RequestContext,
} from "@antelopejs/interface-api";
import { PassThrough } from "node:stream";

class StreamController extends Controller("/stream") {
  @Get()
  async streamData(
    @Context() context: RequestContext,
    @WriteStream("text/event-stream") stream: PassThrough,
  ) {
    stream.write("data: Hello\n\n");

    setTimeout(() => {
      stream.write("data: World\n\n");
      stream.end();
    }, 1000);

    return context.response;
  }
}

@Connection

@Connection() provides the WebSocket connection object of the implementation. On a handler parameter it applies in websocket mode, set by @WebsocketHandler, and in monitor mode, set by @Monitor. A monitor receives the connection for a WebSocket request and undefined for an HTTP request. In handler mode, which @Get, @Post, @Put and @Delete set unless given another mode, and in the prefix and postfix modes of @Prefix and @Postfix, the route decorator throws a TypeError when the class is defined and registers no route for that handler. A child controller method inherits the parameter decorators of the parent method with the same name, so a @Get override of a parent @WebsocketHandler method that uses @Connection throws too: give the HTTP handler another name. On a class property, the value is undefined outside a WebSocket request.

import {
  Controller,
  WebsocketHandler,
  Connection,
} from "@antelopejs/interface-api";

/** The connection methods this handler uses. `@antelopejs/api` passes a `ws` WebSocket. */
interface ChatConnection {
  on(event: "message", listener: (data: Buffer) => void): void;
  send(text: string): void;
}

class ChatController extends Controller("/chat") {
  @WebsocketHandler("")
  handleChat(@Connection() connection: ChatConnection) {
    connection.on("message", (data) => {
      connection.send("Received: " + data.toString());
    });
  }
}

Use decorators on class properties

@Parameter, @MultiParameter, @RawBody, @JSONBody, @BodyStream, @Context, @Connection, and @Transform also apply to class properties. The implementation sets the property values from the request before each handler call.

import {
  Controller,
  Get,
  Parameter,
  MultiParameter,
} from "@antelopejs/interface-api";

class ConfigurableController extends Controller("/config") {
  @Parameter("apiKey", "header")
  private apiKey?: string;

  @MultiParameter("tags", "query")
  private tags!: string[];

  @Get()
  async getConfig() {
    return {
      authenticated: Boolean(this.apiKey),
      appliedTags: this.tags,
    };
  }
}

Combine multiple parameters

A handler can take several decorated parameters from different sources.

import {
  Controller,
  Put,
  Parameter,
  JSONBody,
} from "@antelopejs/interface-api";

class ArticlesController extends Controller("/articles") {
  @Put(":id")
  async updateArticle(
    @Parameter("id") id: string,
    @JSONBody() data: { title: string; content: string },
  ) {
    return { id, ...data, updated: true };
  }
}

Note: Give each parameter exactly one provider decorator. Do not add @Parameter to a parameter that already has @RawBody, @JSONBody, or another provider. A parameter keeps only the last provider set on it.

Parameter providers and modifiers

A provider produces the value of a parameter, and modifiers change it.

Parameter providers

A parameter provider extracts the first value from the request. SetParameterProvider(target, key, index, provider) sets a custom provider.

import {
  SetParameterProvider,
  ReadBody,
  RequestContext,
} from "@antelopejs/interface-api";
import { MakeParameterDecorator } from "@antelopejs/interface-core/decorators";

const RawBody = MakeParameterDecorator((target, key, param) =>
  SetParameterProvider(target, key, param, (context: RequestContext) => {
    return ReadBody(context);
  }),
);

ParameterProvider<Value> takes the type of the value that the provider produces. ParameterModifier<Next> takes the type of the value that the modifier returns. Both type parameters default to unknown. A modifier always receives the previous value as unknown, so narrow it inside the modifier.

import {
  AddParameterModifier,
  SetParameterProvider,
  type ParameterModifier,
  type ParameterProvider,
} from "@antelopejs/interface-api";
import { MakeParameterDecorator } from "@antelopejs/interface-core/decorators";

const authorization: ParameterProvider<string | undefined> = (context) =>
  context.rawRequest.headers.authorization;

const bearerToken: ParameterModifier<string | undefined> = (
  _context,
  header,
) => (typeof header === "string" ? header.replace(/^Bearer /, "") : undefined);

const BearerToken = MakeParameterDecorator((target, key, param) => {
  SetParameterProvider(target, key, param, authorization);
  AddParameterModifier(target, key, param, bearerToken);
});

A provider can read a slot that a controller decorator or a prefix handler sets on the request. Declare that context as the second type parameter, ParameterProvider<Value, Context>, where Context extends RequestContext and defaults to it. SetParameterProvider infers Context from the provider. Nothing checks that the route sets the slot, so register the provider only on routes that do.

import {
  SetParameterProvider,
  type ParameterProvider,
  type RequestContext,
} from "@antelopejs/interface-api";
import { MakeParameterDecorator } from "@antelopejs/interface-core/decorators";

interface TenantContext extends RequestContext {
  tenant: string;
}

const tenant: ParameterProvider<string, TenantContext> = (context) =>
  context.tenant;

const Tenant = MakeParameterDecorator((target, key, param) =>
  SetParameterProvider(target, key, param, tenant),
);

ReadBody keeps the pending read in context.body, typed Promise<Buffer>. It is undefined until a handler reads the body. Later reads of the same request reuse that promise.

Parameter modifiers

A parameter modifier transforms the value after the provider runs. A parameter has one provider but can chain several modifiers. AddParameterModifier(target, key, index, modifier) adds a modifier.

import {
  AddParameterModifier,
  RequestContext,
} from "@antelopejs/interface-api";
import { MakeParameterDecorator } from "@antelopejs/interface-core/decorators";

const EnsureNumber = MakeParameterDecorator((target, key, param) =>
  AddParameterModifier(
    target,
    key,
    param,
    (context: RequestContext, value: unknown) => {
      const num = Number(value);
      return isNaN(num) ? 0 : num;
    },
  ),
);

The EnsureNumber modifier works with the built-in decorators:

import {
  AddParameterModifier,
  Controller,
  Get,
  Parameter,
  RequestContext,
} from "@antelopejs/interface-api";
import { MakeParameterDecorator } from "@antelopejs/interface-core/decorators";

const EnsureNumber = MakeParameterDecorator((target, key, param) =>
  AddParameterModifier(
    target,
    key,
    param,
    (context: RequestContext, value: unknown) => {
      const num = Number(value);
      return isNaN(num) ? 0 : num;
    },
  ),
);

class ExampleController extends Controller("/example") {
  @Get(":id")
  async getExample(@Parameter("id") @EnsureNumber() id: number) {
    return { id, doubled: id * 2 };
  }
}

The @Transform decorator

@Transform(modifier) adds a modifier after the provider. This example accepts positive decimal IDs. A thrown HTTPResult is the response, so a malformed value such as 12abc gets HTTP 400 instead of becoming 12.

import {
  Controller,
  Get,
  HTTPResult,
  Parameter,
  Transform,
  RequestContext,
} from "@antelopejs/interface-api";

function parseId(_context: RequestContext, value: unknown): number {
  if (typeof value !== "string" || !/^[1-9]\d*$/.test(value)) {
    throw new HTTPResult(400, "Expected a positive integer ID");
  }
  const id = Number(value);
  if (!Number.isSafeInteger(id)) {
    throw new HTTPResult(400, "ID is too large");
  }
  return id;
}

class ItemController extends Controller("/items") {
  @Get(":id")
  getItem(@Parameter("id") @Transform(parseId) id: number) {
    return { id };
  }
}

Processing chain

A parameter goes through this chain from extraction to injection:

Provider => [Modifier 1] => [Modifier 2] => ... => Parameter/Property

The provider extracts the raw value. Each modifier then transforms the value in turn, and the handler receives the final result. Each result is awaited. Modifiers run in registration order, which is bottom to top for stacked legacy decorators. A thrown error stops the chain, and the handler does not run.