UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

552 lines (490 loc) 16 kB
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> {}