Architecture
The httpc server
The main task of an httpc server is to:
- translate the incoming standard http request into an httpc request (parsing)
- execute the function linked to the httpc request
- translate the result back into a standard http response (rendering)
The default translation is performed according the httpc call convention, which defines simple rules to identify which server function to call and extract the eventual arguments.
Any incoming request must match a function, otherwise the HTTP 404 is returned.
An httpc request is an HttpCall
object defined with:
property | type | description |
---|---|---|
path | string | the function handler to call, each function is identified by a unique path |
access | "read" | "write" | if the request is a read call or a write call |
metadata | Record<string, any>? | optional metadata enriching the request |
params | any[] | The parameters to pass to the function handler |
Request processing flow
The following chart shows the processing of a single http request, from the parsing phase to the rendering.
The main blocks are
Context
For each request, the server creates a dedicated object, the context, that holds all request specific data. The context can be accessed from anywhere through the request processing.
Parsing
Where the standard http request is translated into an httpc request. A
Parser
is responsible for this task. You can define multiple parsers, the server will pick one according some rules explained in the following section.Preprocessing
Optionally, any
Rewriter
can preprocess the httpc request and transform it before passing it further on. This step is optional, if no rewriters are defined, the httpc request from the parsing stage is left untouched.Pipeline
The pipeline is a set of chained middlewares wrapping the function handler, that is, the actual code defined for that httpc call.
Rendering
Finally, the result (or the error) from the pipeline is transformed into a standard http response by a
Renderer
. Likewise Parsing, you can define multiple renderers, the server will pick the one compatible with the pipeline result.
Context
Before starting the processing flow, the server instantiates a new IHttpCContext
for each incoming request. The context is a simple plain javascript object, where each field represents a context property.
Accessing the context
The context can be accessed from anywhere inside the processing flow.
const context = useContext();
Reading a context property is just a line of code
// accessing property by destructuring
const { requestId } = useContext();
// or use the property hook
const userId = useContextProperty("userId");
both work fine and both have typescript with autocompletion support.
Writing a property to context is easy
const user = readUser(); // getting the user from somewhere
// write the property
useContextProperty("userId", user.id);
// optionally, assign the new value at same time
const userId = useContextProperty("userId", user.id);
You can access the context from anywhere in the execution context of the request. Being inside a Parser
, a Middleware
or a function handler, the context is always available.
There’s no need to pass context data as function parameters in your logic. This has a fundamental impact on how to write code and how to test things.
Builtin properties
property | type | description |
---|---|---|
requestId | string | random uuid auto generated at the beginning |
request | http.IncomingMessage | node native http request |
startedAt | number | unix timestamp as returned by Date.now() |
Extending the context
The context isn’t limited to the predefined properties. Additional properties can be defined with full typescript support.
Usually middlewares process the request and populate the context with common business data like authentication, session info, cached values, … which will be available later in the function handler.
See Extending for details.
Parsing
In the parsing step, the server transforms the http request into an httpc request. This is achieved by an HttpCServerCallParser
.
A httpc request is an HttpCall
object defined with
property | type | description |
---|---|---|
path | string | the function handler to call, each function is identified by a unique path |
access | "read" | "write" | if the request is a read call or a write call |
metadata | Record<string, any>? | optional metadata enriching the request |
params | any[] | The parameters to pass to the function handler |
A parser extracts the required info from the http request and creates the HttpCall
object.
By default, the @httpc/server uses the builtin HttpCCallParser
, which follows the httpc call convention to make calling a server function very easy.
The server is not limited to httpc calls only. It can handle browser form submissions, rest requests, web hooks or any raw http request, with custom parsers. You can have an hybrid server that, in addition to httpc calls, can respond to any http requests.
Each parser tells the server if it can manage the request. In that case, the parser returns the HttpCall
object, otherwise the server will skip to the next parser. Therefore, the order used to define parsers matters.
HttpCCallParser
The builtin HttpCCallParser
provides a straightforward way to expose a json-like API. It transforms an http request into an HttpCall
by following the httpc call convention.
Details and configuration options are available in reference.
Builtin parsers
In addition to json API, the @httpc/server package offers several parsers covering additional use cases. For example, FormUrlEncodedParser
to handle form submissions, or RawBodyParser
to handle binary data.
You can check the reference for details.
Custom parsers
You can define a custom parser to handle a scenario not covered by the builtin parsers. You can define any number of parsers. The order by which the parsers are listed determines the priority.
The factory pattern is often used to define a custom parser.
import { HttpCServerCallParser } from "@httpc/server";
export type CustomerParserOptions = {
// options
}
export function CustomParser(options: CustomerParserOptions) : HttpCServerCallParser | undefined {
return async req => {
if (!shouldParse(req)) {
return; // this parser does not apply to this request, skip to the next
}
// parsing logic and return a call
const call: HttpCall = { /* */ }
return call;
}
}
See Extending for details.
Preprocessing
After the parsing, the resulting HttpCall
can be preprocessed and modified before it executes.
Common scenarios are:
- Rewriting the call path to alter which function to call
- Adding extra metadata to be used later in the function execution
You can define multiple rewriters. The server executes them following the order they are listed in a chain-like way, where the output of the first rewriter become the input of the second one.
The Preprocessing is an optional step. If no rewriters are defined, the HttpCall
continues to the next step, with no modification.
Pipeline
On each incoming HttpCall
, the server looks up for the relative function to execute. The match is based on the call path. When the sever starts, it setups one pipeline for each function you defined and associates each one with a unique path. More about the call path in the Call nesting and composition section.
After the function is found, the server executes it. If no function is found, the server will respond with the standard HTTP 404.
Each function defines its own pipeline with middlewares.
import { httpCall } from "@httpc/server";
const getUserById = httpCall(
Authenticated(), // <-- authentication middleware
Validate(String), // <-- validation middleware
Cache("5minutes"), // <-- caching middleware
// actual function logic
async (userId: string) => {
return await db.select("users").where("id", userId).first();
}
);
Middlewares
A pipeline is a chain of middlewares with the function handler itself at the last step. A middleware is a piece of code that wraps the execution of the next middleware in the chain. The last middleware wraps the actual function handler. Thus, the sequence of execution is defined by the order by witch the middlewares are defined.
A middleware is typed by HttpCServerMiddleware
. Usually a middleware is defined with a factory, a function the returns the middleware itself.
import { HttpCServerMiddleware } from "@httpc/server";
export function CustomMiddleware(): HttpCServerMiddleware {
return async (call, next) => {
// middleware logic
// with a call to the next middleware somewhere
return await next(call);
};
}
With a middleware you can execute shared logic among all your functions. Common scenarios include:
Pre checks
Run code before the function and block it if necessaryPassthrough
Run code before and after the function with no impact on the function logicAfter processing/Catches
Run code after the function or capture errors in the following steps
import { HttpCServerMiddleware, UnauthorizedError } from "@httpc/server";
//
// Allow only authenticated users
//
export function AuthenticatedMiddleware(): HttpCServerMiddleware {
return async (call, next) => {
const { user } = useContext();
if(!user) {
throw new UnauthorizedError();
}
return await next(call);
};
}
import { HttpCServerMiddleware } from "@httpc/server";
//
// Measure the call execution time
//
export function DurationMiddleware(): HttpCServerMiddleware {
return async (call, next) => {
const start = Date.now();
const result = await next(call);
console.log(`Call[${call.path}] time: ${Date.now() - start}ms`);
return result;
};
}
import { HttpCServerMiddleware } from "@httpc/server";
//
// Catch all errors and returns a plain object
//
export function CatchErrorMiddleware(): HttpCServerMiddleware {
return async (call, next) => {
try {
return await next(call);
} catch(err) {
return {
success: false,
error: error.message,
};
}
};
}
A middleware can be applied globally, on a specific sub area or on a single call.
// file: users.calls.ts
const getUserById = httpCall(
Validate(String), // <-- validation middleware
Cache("5minutes"), // <-- caching middleware
async (userId: string) => {
// omitted logic
}
);
const createUser = httpCall(
Validate(UserCreateModel) // <-- validation middleware,
async (data: UserCreateModel) => {
// omitted logic
}
);
export default {
getUserById,
createUser
};
import { httpGroup } from "@httpc/server";
import usersCalls from "./users.calls";
// require authentication for all user calls
export default httpGroup(
Authenticated(), // <-- authentication check middleware
userCalls
);
import { createHttpCServer } from "@httpc/server";
import { RequestLoggerMiddleware, ErrorHandlerMiddleware } from "./middlewares";
import calls from "./calls";
const server = createHttpCServer({
calls,
middlewares: [
RequestLoggerMiddleware({ level: "info" }),
ErrorHandlerMiddleware(),
]
});
server.start();
Call nesting and composition
Each call is associated with a unique path, in a tree-like folder/file structure.
You can arrange and group functions as you like. For example you can have users
and articles
functions in the following structure
/users
/getById
/create
/update
/articles
/getById
/getByUserId
For the getById
users function, the call the path is: /users/getById
and, from the client, the code to call the function will be
const user = await client.users.getById(...);
You can have a flat structure where all functions are available at the first level, or you can nest and group them. The structure is just a plain object where each property represents a function call.
To group functions just assign them to a property, as a sub-object.
const getById = httpCall( /** omitted */ );
const create = httpCall( /** omitted */ );
export default {
users: { // <-- users function group
getById,
create
}
}
From the client, the calls will be available with the users
property.
const user = await client.users.getById(userId);
const newUser = await client.users.create({
username: "[email protected]",
password: "strong-password"
});
Or, for better code organization, you can compose calls with standard imports. Define all users calls inside a dedicated file
// file: users.calls.ts
const getById = httpCall( /** omitted */ );
const create = httpCall( /** omitted */ );
export default {
getById,
create
}
and import them in the main calls file
// file: calls.ts
import users from "./users.calls";
import articles from "./articles.calls";
export default {
users,
articles,
}
You can further nest calls group inside another one. Because a call group is a simple javascript object, you can nest calls with a simple property assignment.
For example if you want friends
calls as sub calls within users
, just set a property friends
on the users
call object.
// file: friends.calls.ts
const add = httpCall(
Validate(String, String, String),
async (userId: string, friendId: string, listId: String) => {
// omitted
}
);
export default {
add
}
You can import calls and compose them, like there are a simple object.
// file: users.calls.ts
import friends from "./friends.calls";
const getById = httpCall( /** omitted */ );
export default {
friends, // <-- composing all friends calls as nested
getById,
}
From the client, the nested calls are available as properties.
const friend = await client.users.friends.add(userId, friendId, "friend-list-2");
Result
Anything a function handler returns will be the result. An handler can also throw an error to halt the execution. There are several builtin errors that map to the relative http error like BadRequestError
, UnauthorizedError
and so on.
If an error is thrown, the server will catch it, stop the pipeline execution and the error itself will be the result.
The pipeline result, either the object returned by the function handler (maybe post processed by the middleware chain) or the error if the an exception is thrown, will be passed to the rendering step.
Rendering
In the rendering step, the server transforms the pipeline result into an actual http response. This is achieved by several renderers.
An HttpCServerRenderer
transforms the pipeline result into an HttpCServerResponse
. You can define multiple renderers to handle different use cases, like a json response, an html template render, a redirect or, in general, anything the http protocol supports.
The @httpc/server package offers some builtin renderers. In addition, custom renderers can be defined.
import { HttpCServerRenderer, HttpCServerResponse } from "@httpc/server";
export function MyCustomRenderer() : HttpCServerRenderer {
return async (result: unknown) : HttpCServerResponse | undefined => {
if (!canIRenderThis(result)) {
return; // this render doesn't handle this result, skip to the next render
}
// transform logic
const response = /* omitted */
return response;
};
}
The server runs the renders in order. Thus the definition order matters as it sets the run sequence. A renderer can skip the transformation, if it cannot handle the result. When a renderer returns a response, the rendering phase stops, no additional renderers is executed and the response sent to the client.
As the @httpc/server is tailored for the json-API scenario, by default the builtin JsonRenderer
is added to the renderer list. The JsonRenderer
serializes any result object into json and sent it to the client.
A pipeline result can be:
undefined
ornull
If the handler returns nothing, the server will respond with a standard http 204 No Content
HttpCServerResponse
An handler can return an
HttpCServerResponse
to bypass the rendering phase. In this case, no renderer is run and the result, which is a already a response, is sent directly to the client.an object instanceof
Error
If the handler throws an error or the server catches an error during the pipeline execution (ie, inside middlewares), the result will an
Error
instance.any other object instance
Anything else will be processed as is by the renderers.