alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
552 lines (490 loc) • 16 kB
text/typescript
import {
$atom,
$inject,
$state,
AlephaError,
type Async,
createPrimitive,
isTypeFile,
KIND,
PipelinePrimitive,
type PipelinePrimitiveOptions,
type SchemaOutput,
type Static,
z,
} from "alepha";
import { $logger } from "alepha/logger";
import type { RouteMethod } from "../constants/routeMethods.ts";
import { isMultipart } from "../helpers/isMultipart.ts";
import { ServerReply } from "../helpers/ServerReply.ts";
import type {
RequestConfigSchema,
ServerRequest,
ServerResponseBody,
ServerRoute,
} from "../interfaces/ServerRequest.ts";
import { ServerProvider } from "../providers/ServerProvider.ts";
import { ServerRouterProvider } from "../providers/ServerRouterProvider.ts";
import {
type FetchOptions,
type FetchResponse,
HttpClient,
} from "../services/HttpClient.ts";
/**
* Creates a server action primitive for defining type-safe HTTP endpoints.
*
* Server actions are the core building blocks for REST APIs in Alepha, providing declarative
* HTTP endpoints with full type safety, automatic validation, and OpenAPI documentation.
*
* **Key Features**
* - Full TypeScript inference for request/response types
* - Automatic schema validation using TypeBox
* - Convention-based URL generation with customizable paths
* - Direct invocation (`run()`) or HTTP requests (`fetch()`)
* - Built-in authentication and authorization support
* - Automatic content-type handling (JSON, form-data, plain text)
*
* **URL Generation**
*
* **Important:** All `$action` paths are automatically prefixed with `/api`.
*
* ```ts
* $action({ path: "/users" }) // → GET /api/users
* $action({ path: "/users/:id" }) // → GET /api/users/:id
* $action({ path: "/hello" }) // → GET /api/hello
* ```
*
* This prefix is configurable via the `SERVER_API_PREFIX` environment variable.
* HTTP method defaults to GET, or POST if body schema is provided.
*
* **Common Use Cases**
* - CRUD operations with type safety
* - File upload and download endpoints
* - Microservice communication
*
* @example
* ```ts
* class UserController {
* getUsers = $action({
* path: "/users",
* schema: {
* query: z.object({
* page: z.number({ default: 1 }).optional(),
* limit: z.number({ default: 10 }).optional()
* }),
* response: z.object({
* users: z.array(z.object({
* id: z.text(),
* name: z.text(),
* email: z.text()
* })),
* total: z.number()
* })
* },
* handler: async ({ query }) => {
* const users = await this.userService.findUsers(query);
* return { users: users.items, total: users.total };
* }
* });
*
* createUser = $action({
* method: "POST",
* path: "/users",
* schema: {
* body: z.object({
* name: z.text(),
* email: z.text({ format: "email" })
* }),
* response: z.object({ id: z.text(), name: z.text() })
* },
* handler: async ({ body }) => {
* return await this.userService.create(body);
* }
* });
* }
* ```
*/
export const $action = <TConfig extends RequestConfigSchema>(
options: ActionPrimitiveOptions<TConfig>,
): ActionPrimitiveFn<TConfig> => {
const instance = createPrimitive(ActionPrimitive<TConfig>, options);
const fn = (
config?: ClientRequestEntry<TConfig>,
options?: ClientRequestOptions,
) => {
return instance.run(config, options);
};
Object.defineProperty(fn, "name", {
get(): string {
return instance.options.name || instance.config.propertyKey;
},
});
return Object.setPrototypeOf(fn, instance) as ActionPrimitiveFn<TConfig>;
};
// ----------------------------------------------------------------------------------------------------------
export interface ActionPrimitiveOptions<TConfig extends RequestConfigSchema>
extends Omit<ServerRoute, "handler" | "path" | "schema" | "mapParams">,
PipelinePrimitiveOptions {
/**
* Name of the action.
*
* - It will be used to generate the route path if `path` is not provided.
* - It will be used to generate the permission name if `security` is enabled.
*/
name?: string;
/**
* Group actions together.
*
* - If not provided, the service name containing the route will be used.
* - It will be used as Tag for documentation purposes.
* - It will be used for permission name generation if `security` is enabled.
*
* @example
* ```ts
* // group = "MyController"
* class MyController {
* hello = $action({ handler: () => "Hello World" });
* }
*
* // group = "users"
* class MyOtherController {
* group = "users";
* a1 = $action({ handler: () => "Action 1", group: this.group });
* a2 = $action({ handler: () => "Action 2", group: this.group });
* }
* ```
*/
group?: string;
/**
* Pathname of the route. If not provided, property key is used.
*/
path?: string;
/**
* The route method.
*
* - If not provided, it will be set to "GET" by default.
* - If not provider and a body is provided, it will be set to "POST".
*
* Wildcard methods are not supported for now. (e.g. "ALL", "ANY", etc.)
*/
method?: RouteMethod;
/**
* The config schema of the route.
* - body: The request body schema.
* - params: Path variables schema.
* - query: The request query-params schema.
* - response: The response schema.
*/
schema?: TConfig;
/**
* A short description of the action. Used for documentation purposes.
*/
description?: string;
/**
* Disable the route. Useful with env variables to disable one specific route.
* Route won't be available in the API nor locally.
*/
disabled?: boolean;
/**
* Main route handler. This is where the route logic is implemented.
*/
handler: ServerActionHandler<TConfig>;
}
// ----------------------------------------------------------------------------------------------------------
/**
* Server API configuration atom.
*/
export const serverApiOptions = $atom({
name: "alepha.server.api.options",
schema: z.object({
prefix: z.text({
default: "/api",
description: "Prefix for all API routes (e.g. $action).",
}),
}),
default: {
prefix: "/api",
},
});
export type ServerApiOptions = Static<typeof serverApiOptions.schema>;
declare module "alepha" {
interface State {
[serverApiOptions.key]: ServerApiOptions;
}
}
// ----------------------------------------------------------------------------------------------------------
export class ActionPrimitive<
TConfig extends RequestConfigSchema,
> extends PipelinePrimitive<ActionPrimitiveOptions<TConfig>> {
protected readonly log = $logger();
protected readonly settings = $state(serverApiOptions);
protected readonly httpClient = $inject(HttpClient);
protected readonly serverProvider = $inject(ServerProvider);
protected readonly serverRouterProvider = $inject(ServerRouterProvider);
protected onInit() {
if (this.options.disabled) {
this.log.debug(
`Action '${this.name}' is disabled. It won't be available in the API.`,
);
return;
}
this.serverRouterProvider.createRoute(this.route);
}
public get prefix() {
return this.settings.prefix;
}
public get route(): ServerRoute {
return {
...this.options,
method: this.method,
path: `${this.prefix}${this.path}`,
handler: this.handler,
} as ServerRoute;
}
/**
* Returns the name of the action.
*/
public get name(): string {
return this.options.name || this.config.propertyKey;
}
/**
* Returns the group of the action. (e.g. "orders", "admin", etc.)
*/
public get group(): string {
return this.options.group || this.config.service.name;
}
/**
* Returns the HTTP method of the action.
*/
public get method(): RouteMethod {
return this.options.method || (this.options.schema?.body ? "POST" : "GET");
}
/**
* Returns the path of the action.
*
* Path is prefixed by `/api` by default.
*/
public get path(): string {
if (this.options.path) {
return this.options.path;
}
let path = `/${this.name}`;
if (this.options.schema?.params) {
for (const [key] of Object.entries(
this.options.schema.params.properties,
)) {
path += `/:${key}`;
}
}
return path;
}
public get schema(): TConfig | undefined {
return this.options.schema;
}
public getBodyContentType(): string | undefined {
if (this.options.schema?.body) {
// TODO: move to `alepha.server.multipart` module ?
if (isMultipart(this.options)) {
return "multipart/form-data";
}
if (z.schema.isString(this.options.schema.body)) {
// if body is a string, we assume it's plain text
return "text/plain";
}
if (
z.schema.isObject(this.options.schema.body) ||
z.schema.isArray(this.options.schema.body) ||
z.schema.isRecord(this.options.schema.body)
)
// if body is an object or array, we assume it's JSON
return "application/json";
}
}
/**
* Call the action handler directly.
* There is no HTTP layer involved.
*/
public async run(
config?: ClientRequestEntry<TConfig>,
options: ClientRequestOptions = {}, // most of the options are ignored here
): Promise<ClientRequestResponse<TConfig>> {
if (this.options.disabled) {
throw new AlephaError(`Action '${this.name}' is disabled.`);
}
const handler = this.handler.run.bind(this.handler);
const {
body,
params = {},
query = {},
headers = {},
} = (config ?? {}) as ClientRequestEntryContainer<RequestConfigSchema>;
const reply = new ServerReply();
const method = this.method;
// we use localhost as the base URL for the action
const url = new URL(`http://localhost${this.path ?? ""}`);
const serverActionRequest: Partial<ServerRequest> = {
method,
url,
body,
params,
query,
headers,
reply,
metadata: {
routePath: this.route.path,
routeMethod: this.route.method,
},
};
const eventData: {
action: ActionPrimitive<TConfig>;
request: ServerRequest;
options: ClientRequestOptions;
context?: Record<string, any>;
} = {
action: this,
request: serverActionRequest as ServerRequest,
options,
};
await this.alepha.events.emit("action:onRequest", eventData as any);
if (serverActionRequest.reply?.body) {
return serverActionRequest.reply.body as ClientRequestResponse<TConfig>;
}
const executeHandler = async (): Promise<
ClientRequestResponse<TConfig>
> => {
if (serverActionRequest.query && this.options.schema?.query) {
serverActionRequest.query = this.alepha.codec.encode(
this.options.schema.query,
serverActionRequest.query,
);
}
if (serverActionRequest.headers && this.options.schema?.headers) {
// Per-key encode (matches the server-side decode pattern in
// ServerRouterProvider.validateRequest): coerces declared headers via
// the schema, leaves undeclared ones untouched. Schema keys are
// lowercased to match Node's incoming header convention.
const schemaHeaders = this.options.schema.headers;
const headers = serverActionRequest.headers as Record<string, unknown>;
for (const key of Object.keys(schemaHeaders.properties)) {
const lcKey = key.toLowerCase();
if (headers[lcKey] !== undefined) {
headers[lcKey] = this.alepha.codec.encode(
schemaHeaders.properties[key],
headers[lcKey],
);
}
}
}
if (serverActionRequest.body && this.options.schema?.body) {
serverActionRequest.body = this.alepha.codec.encode(
this.options.schema.body,
serverActionRequest.body,
) as unknown;
}
if (serverActionRequest.params && this.options.schema?.params) {
serverActionRequest.params = this.alepha.codec.encode(
this.options.schema.params,
serverActionRequest.params,
) as Record<string, any>;
}
this.serverRouterProvider.validateRequest(
this.options,
serverActionRequest as ServerRequest,
);
let response: any = await handler(
serverActionRequest as ServerActionRequest<TConfig>,
);
// we validate response just to remove undeclared properties from response
if (
this.options.schema?.response &&
// skip validation if response is expected as file
!isTypeFile(this.options.schema.response)
) {
response = this.alepha.codec.validate(
this.options.schema.response,
response,
);
}
await this.alepha.events.emit("action:onResponse", {
action: this,
request: serverActionRequest as ServerRequest,
options,
response,
});
return response;
};
// Always fork to isolate each .run() call in its own ALS context.
// The synthetic action request is separate from any real HTTP request.
// Hook fork data (e.g. user atom) is merged into the context.
const forkData: Record<string, any> = {
"alepha.action.request": serverActionRequest,
...eventData.context,
};
return this.alepha.fork(executeHandler, forkData);
}
/**
* Works like `run`, but always fetches (http request) the route.
*/
public fetch(
config?: ClientRequestEntry<TConfig>,
options?: ClientRequestOptions,
): Promise<FetchResponse<ClientRequestResponse<TConfig>>> {
return this.httpClient.fetchAction({
host: this.serverProvider.hostname, // that's the trick, we just use the server hostname
action: this,
config,
options,
});
}
}
export interface ActionPrimitiveFn<TConfig extends RequestConfigSchema>
extends ActionPrimitive<TConfig> {
(
config?: ClientRequestEntry<TConfig>,
options?: ClientRequestOptions,
): Promise<ClientRequestResponse<TConfig>>;
}
$action[KIND] = ActionPrimitive;
// ----------------------------------------------------------------------------------------------------------
export type ClientRequestEntry<
TConfig extends RequestConfigSchema,
T = ClientRequestEntryContainer<TConfig>,
> = {
[K in keyof T as T[K] extends undefined ? never : K]: T[K];
};
export type ClientRequestEntryContainer<TConfig extends RequestConfigSchema> = {
body: SchemaOutput<TConfig["body"], undefined>;
params: SchemaOutput<TConfig["params"], undefined>;
headers?: SchemaOutput<TConfig["headers"], Record<string, string>>;
query?: Partial<SchemaOutput<TConfig["query"], Record<string, string>>>;
};
export interface ClientRequestOptions extends FetchOptions {
/**
* Standard request fetch options.
*/
request?: RequestInit;
/**
* Add query parameters to the request URL. They will be merged with any query params defined in the action schema.
* This is useful for adding dynamic query params at runtime.
*/
query?: Record<string, string | number | boolean>;
}
export type ClientRequestResponse<TConfig extends RequestConfigSchema> =
SchemaOutput<TConfig["response"], any>;
/**
* Specific handler for server actions.
*/
export type ServerActionHandler<
TConfig extends RequestConfigSchema = RequestConfigSchema,
> = (
request: ServerActionRequest<TConfig>,
) => Async<ServerResponseBody<TConfig>>;
/**
* Server Action Request Interface
*
* Can be extended with module augmentation to add custom properties (like `user` in Server Security).
*
* This is NOT Server Request, but a specific type for actions.
*/
export interface ServerActionRequest<TConfig extends RequestConfigSchema>
extends ServerRequest<TConfig> {}