Procedural macro for exporting actix-web API endpoint contracts as TypeScript.
#[api_endpoint(method = "GET", path = "users/profile")]
pub(crate) async fn get_user_profile(
pool: web::Data<DbPool>,
request: HttpRequest,
filter: web::Query<FilterQuery>,
) -> Result<Json<ApiResult<ProfileResponse>>> {
// …
}Running cargo test export_endpoint writes three files per handler (plus one shared file):
<APIM_EXPORT_DIR>/<APIM_ENDPOINTS_PATH>/<Name>.ts— typed spec object consumed bycallEndpoint<APIM_EXPORT_DIR>/<APIM_API_PATH>/<Name>.ts— thin async wrapper that callscallEndpointwith the right types<APIM_EXPORT_DIR>/<APIM_ENDPOINTS_PATH>/EndpointSpec.ts— sharedEndpointSpecandHttpMethodtypes (same content every time; safe to write from multiple handlers)
Set the following in your project's .cargo/config.toml under [env].
The TypeScript module path from which callEndpoint is imported. Used verbatim in the
generated import statement:
import { callEndpoint } from 'client/services/http';Omitting this variable is a compile error.
[env]
APIM_CALL_ENDPOINT_MODULE = "client/services/http"The name of the function exported by APIM_CALL_ENDPOINT_MODULE. Override this if your
module exports it under a different name:
[env]
APIM_CALL_ENDPOINT_NAME = "fetchEndpoint"The name of the response-wrapper type used in generated return-type annotations and imports.
[env]
APIM_RESULT_TYPE = "ApiResult"The TypeScript module path from which APIM_RESULT_TYPE is imported:
[env]
APIM_RESULT_PATH = "bindings/ApiResult"Root directory for all generated output files, relative to CARGO_MANIFEST_DIR:
[env]
APIM_EXPORT_DIR = "frontend/src/generated"Sub-path under APIM_EXPORT_DIR where endpoint binding files are written. The generated API
wrapper imports its spec file using a filesystem-relative path computed from APIM_API_PATH
to this path:
[env]
APIM_ENDPOINTS_PATH = "bindings/endpoints"Sub-path under APIM_EXPORT_DIR where generated API wrapper files are written:
[env]
APIM_API_PATH = "api"Default value for the depth macro argument when it is not specified in the attribute.
Useful when all (or most) handlers in a project share the same scope nesting level:
[env]
APIM_DEPTH_DEFAULT = "2"Relative TypeScript module path used as the parent directory when
importing model types referenced by an endpoint's query/body/path-params/response roles
(e.g. import type { Foo } from "{APIM_MODELS_PATH}/Foo"). Change this if model files don't
live one directory above the generated endpoint binding files:
[env]
APIM_MODELS_PATH = "../models"If non-empty, this type name is accepted in handler return types as an alias for
Json<{APIM_RESULT_TYPE}<T>>. Useful for codebases that define a type alias such as
type JsonApiResult<T> = Json<ApiResult<T>>:
[env]
APIM_JSON_API_RESULT_ALIAS = "JsonApiResult"If non-empty, accepted as an alias for CustomizeResponder<Json<{APIM_RESULT_TYPE}<T>>>:
[env]
APIM_CUSTOMIZERESPONDER_JSON_API_RESULT_ALIAS = "CustomizedJsonApiResult"When set to 1, true, or yes, switches the macro to unwrapped mode. In this mode:
- Handler return types must omit the
APIM_RESULT_TYPElayer:Result<Json<T>>instead ofResult<Json<ApiResult<T>>>. - Generated TypeScript wrappers return
Promise<T>directly instead ofPromise<ApiResult<T>>. - The
APIM_RESULT_TYPEimport is omitted from generated API wrapper files entirely.
[env]
APIM_UNWRAPPED_RESPONSE = "true"This is useful when the consuming project handles API errors at a lower layer (e.g. in the
callEndpoint implementation itself) and callers always receive the successful response type
directly.
The module must export a function compatible with the following signature.
ApiResult<R> (or whatever APIM_RESULT_TYPE is set to) is imported by the generated
files from APIM_RESULT_PATH.
export declare function callEndpoint<Q, B, P, R>(
spec: EndpointSpec<Q, B, P, R>,
args?: {
query?: Q;
body?: B;
pathParams?: P;
},
options?: Omit<RequestInit, 'method'>,
): Promise<ApiResult<R>>;callEndpoint should only throw on network or parse failures. HTTP-level errors
(non-2xx responses) must be returned inside ApiResult rather than thrown, so
callers can handle them without try/catch.
The options parameter is forwarded from the generated wrapper and should be
merged with whatever RequestInit options the implementation applies by default
(e.g. credentials, base URL, auth headers). Callers use it to pass per-request
overrides such as signal for abort control.
The required signature changes so the return type is the raw response type R:
export declare function callEndpoint<Q, B, P, R>(
spec: EndpointSpec<Q, B, P, R>,
args?: {
query?: Q;
body?: B;
pathParams?: P;
},
options?: Omit<RequestInit, 'method'>,
): Promise<R>;In this mode, callEndpoint is responsible for throwing (or otherwise surfacing) HTTP errors
itself, since there is no wrapper to carry them back to the caller.
These types are defined in EndpointSpec.ts, which ships with this crate and is copied
verbatim into <APIM_EXPORT_DIR>/<APIM_ENDPOINTS_PATH>/ by the generated tests. You do
not need to author or maintain them yourself.
export type HttpMethod =
| 'GET'
| 'POST'
| 'PUT'
| 'PATCH'
| 'DELETE'
| 'HEAD'
| 'OPTIONS';
export interface EndpointSpec<Q, B, P, R> {
readonly method: HttpMethod;
readonly path: string;
readonly _phantom?: readonly [Q, B, P, R];
}The generated endpoint binding files import EndpointSpec from ./EndpointSpec (a relative
import co-located with the generated files).
| Argument | Required | Description |
|---|---|---|
method |
yes | HTTP verb, e.g. "GET", "POST" |
path |
yes | URL path relative to the API root, e.g. "users/profile" |
name |
no | Override the generated PascalCase TypeScript name (defaults to method + path segments) |
depth |
no | Number of leading path segments to strip for the actix route path (default: APIM_DEPTH_DEFAULT) |
The macro emits #[actix_web::get("…")] (or the appropriate verb) on the handler function,
making it an HttpServiceFactory registrable with cfg.service(fn_name). The path passed to
that actix attribute must be relative to the scope the handler is registered under — not the
full canonical path.
depth controls how many leading segments are stripped from path to form the actix
route path.
path = "users/profile"
depth = 1 → actix route = "profile" (registered under /users scope)
depth = 2 → actix route = "" (scope root of /users/profile)
path = "user/info"
depth = 1 → actix route = "info" (registered under /user scope)
depth = 2 → actix route = "" (scope root of /user/info)
The TypeScript path constant always uses path verbatim, regardless of depth.
The macro inspects the handler's parameter list to determine which roles are present:
web::Query<T>→ query-string type (T)web::Json<T>→ request-body type (T)web::Path<T>→ path-parameter type (T)
The return type must be one of the forms below, depending on APIM_UNWRAPPED_RESPONSE.
Wrapped mode (default):
Result<Json<ApiResult<T>>>— standard JSON responseResult<CustomizeResponder<Json<ApiResult<T>>>>— customized JSON responseResult<Either<L, R>>whereLandRare either of the above → union responseT_L | T_R
(ApiResult here refers to whatever APIM_RESULT_TYPE is set to.)
If APIM_JSON_API_RESULT_ALIAS or APIM_CUSTOMIZERESPONDER_JSON_API_RESULT_ALIAS are set,
those names are also accepted.
Unwrapped mode (APIM_UNWRAPPED_RESPONSE = true):
Result<Json<T>>— standard JSON responseResult<CustomizeResponder<Json<T>>>— customized JSON responseResult<Either<L, R>>whereLandRare either of the above → union responseT_L | T_R
Generated API wrapper functions follow the argument order: pathParams first,
then body, then query (optional), then options (optional). Required
parameters precede optional ones; options is always last so callers can pass
per-request RequestInit overrides (e.g. signal) without touching the other
arguments.