HTTP APIs
HTTP in Cell is two packages: @celljs/web (server, middleware chain, body parsing, cookies, sessions, CORS, errors) and @celljs/mvc (controllers on top of it). Both run on Node's node:http with no third-party framework. Import the decorators from @celljs/mvc/lib/node and the request-level types from @celljs/web/lib/node.
Controllers and routes
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@celljs/mvc/lib/node';
@Controller('users') // prefix: /users
export class UserController {
@Get() // GET /users?role=admin
list(@Query('role') role?? string) { /* ... */ }
@Get(':id') // GET /users/42
get(@Param('id') id: number) { /* ... */ }
@Post()
create(@Body() user: CreateUser) { /* ... */ }
@Patch(':id')
rename(@Param('id') id: number, @Body('name') name: string) { /* ... */ }
@Delete(':id')
remove(@Param('id') id: number): void { /* ... */ }
}- Method decorators:
@Get,@Post,@Put,@Patch,@Delete,@Head,@Options,@All(every method). Without a path the method answers the controller path itself. - Paths:
:nameis one segment,*matches anything,(...)is optional.server.path(default/) prefixes every route. - A request no route matches gets
404. - Controllers are components: inject services with
@Autowired, read configuration with@Value. Import the controller's file fromsrc/node/module.ts.
Parameters
| Decorator | Value |
|---|---|
@Param('id') / @Param() | One path parameter / all of them |
@Query('q') / @Query() | One query parameter / the parsed query (?a[b]=1 → { a: { b: '1' } }) |
@Body('name') / @Body() | One field / the whole body |
@Header('x-api-key') / @Header() | One request header / all headers |
@Cookie('sid') | A request cookie |
@Session('cart') | A value of the session |
@Req() / @Res() | Node's request and response (IncomingMessage / ServerResponse with extras) |
Parameters typed string, number, boolean or Date are converted ('42' → 42). The body is parsed from JSON, text/plain, application/octet-stream (a Buffer) or a URL-encoded form; gzip, deflate and br bodies are decompressed. Malformed JSON answers 400, a body over web.body.limit (default 100kb) answers 413.
Validating the body
Add @celljs/validation and class-validator, then type the parameter with a class:
import { IsString, Length } from 'class-validator';
export class CreateUser {
@IsString()
@Length(1, 20)
name: string;
}
@Post()
create(@Body() user: CreateUser) { /* user is a CreateUser instance and is valid */ }An invalid body answers 400 with the errors as JSON; the method is not called. Options are under validation (@celljs/validation).
Responses
| Return / decorator | Response |
|---|---|
| an object, array, number, ... | JSON (application/json); the default view |
@Text() and a string | text/plain |
@Html('page.mustache') and an object | the Mustache template from mvc.mustache.baseViewDir (default assets/views) rendered with it |
@File() and a Buffer or a stream | the bytes, streamed |
undefined (void) | 200 with an empty body |
@Header('cache-control', 'no-store') | adds a response header |
@Cookie('flavor', 'oat') / @Session('step', 'two') | sets a cookie / a session value |
async methods work the same way. To choose the status code, take the response: @Res() res: Response (with import type { Response } from '@celljs/web/lib/node': it is an interface) or Context.getResponse(), and set res.statusCode = 201, then return the body as usual.
Errors
import { HttpError, NotFoundError } from '@celljs/web/lib/node';
throw new NotFoundError('No user 42'); // 404
throw new HttpError(409, 'Name already taken'); // any status
throw new Error('boom'); // 500The status comes from HttpError.statusCode; any other error answers 500. Stack traces are never sent to the client: a 500 is logged with its stack (and its cause) at the error level, a 4xx HttpError (or an error a handler answered with a redirect or a 4xx) only at the debug level. The client gets the message of a 4xx HttpError; in production a 5xx or any other error answers only its status text, unless the HttpError was created with { expose: true } or web.errors.exposeMessages is set. Per controller, a method marked @Catch(ErrorType) handles the errors of that type thrown in the controller, and its return value is the response:
@Catch(QuotaError)
@Text()
onQuota(err: QuotaError) { return `Quota exceeded: ${err.message}`; }For the whole application, add an ErrorHandler. Handlers are asked in order of priority (highest first) whether they canHandle the error; the first that can handles it, and if its handle throws, the next one is tried. The built-in handlers have priorities up to 2000, so give yours more to run first:
import { Component } from '@celljs/core';
import { Context, ErrorHandler } from '@celljs/web/lib/node';
@Component(ErrorHandler)
export class QuotaErrorHandler implements ErrorHandler {
readonly priority = 2100;
async canHandle(ctx: Context, err: Error) { return err instanceof QuotaError; }
async handle(ctx: Context, err: Error) {
ctx.response.statusCode = 429;
ctx.response.setHeader('content-type', 'application/json');
ctx.response.end(JSON.stringify({ error: err.message }));
}
}Middleware
A middleware is a component bound to Middleware. It sees every request before the controllers; higher priority runs earlier.
import { Component } from '@celljs/core';
import { Context, HTTP_MIDDLEWARE_PRIORITY, HttpError, Middleware } from '@celljs/web/lib/node';
@Component(Middleware)
export class ApiKeyMiddleware implements Middleware {
readonly priority = HTTP_MIDDLEWARE_PRIORITY - 10; // after trace, CORS and body parsing
async handle(ctx: Context, next: () => Promise<void>): Promise<void> {
if (ctx.request.path.startsWith('/admin') && ctx.request.get('x-api-key') !== process.env.API_KEY) {
throw new HttpError(401, 'Missing or wrong API key');
}
await next();
}
}Middleware written for express that only needs Node's req / res can be wrapped with convertExpressMiddleware(fn).
The request context
Anywhere during a request, also in services far from the controller, Context from @celljs/web/lib/node gives the current request: Context.getRequest(), Context.getResponse(), Context.getSession(), Context.getCookies(), Context.getTraceId(), and request-scoped attributes with Context.setAttr(key, value) / Context.getAttr(key). It follows await, timers and promise chains.
Built in
| Feature | Configuration |
|---|---|
Trace id: every response has an X-Cell-Trace-ID header (Context.getTraceId()) | web.trace |
Health check: GET /health | web.health |
| CORS headers, every origin by default | web.cors (origin, methods, credentials, ...) |
| Signed cookies and sessions stored in a cookie | web.cookies.keys, web.session. Set your own web.cookies.keys in production |
| Request body limits | web.body |
The @celljs/web reference has every option.
More
| Need | Package |
|---|---|
OpenAPI document (/openapi.json) and Swagger UI (/api-docs) from the controllers | @celljs/openapi |
Login, sessions, @Authenticated(), @PreAuthorize() | @celljs/security |
| JSON Web Tokens | @celljs/jwt |
| Serving the frontend build | @celljs/serve-static |
| Typed calls from the frontend or other services | Frontend and RPC |
| gRPC services | @celljs/grpc |