Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
246 changes: 167 additions & 79 deletions docs/en/low-code/custom-endpoints.md
Original file line number Diff line number Diff line change
@@ -1,149 +1,237 @@
```json
//[doc-seo]
{
"Description": "Define custom REST API endpoints with JavaScript handlers in the ABP Low-Code System. Create dynamic APIs without writing C# controllers."
"Description": "Define JavaScript-backed custom REST endpoints in the ABP Low-Code System without writing custom .NET controllers."
}
```

# Custom Endpoints

Custom Endpoints allow you to define REST API routes with server-side JavaScript handlers directly in `model.json`. Each endpoint is registered as an ASP.NET Core endpoint at startup and supports hot-reload when the model changes.
> **Preview:** Custom endpoint descriptors and scripting helpers are part of the preview Low-Code System. Names, validation rules, and runtime behavior may change before general availability.

## Defining Endpoints
Use generated page CRUD APIs for normal dynamic entity pages. Custom endpoints are an advanced option for exposing small model-owned REST APIs that do not map to standard list, get, create, update, delete, export, file, or attachment operations.

Custom endpoints are defined in JSON descriptor files or through the Low-Code Designer. Each endpoint executes server-side JavaScript and is registered as an ASP.NET Core route.

Add endpoints to the `endpoints` array in `model.json`:
## Defining Endpoints

```json
{
"endpoints": [
{
"name": "GetProductStats",
"route": "/api/custom/products/stats",
"name": "GetCampaignStats",
"route": "/api/custom/campaigns/stats",
"method": "GET",
"description": "Get product statistics",
"requireAuthentication": false,
"javascript": "var count = await db.count('LowCodeDemo.Products.Product');\nreturn ok({ totalProducts: count });"
"description": "Get campaign statistics",
"requireAuthentication": true,
"requiredPermissions": ["Acme.Campaigns"],
"javascript": "var count = await db.count('Acme.Campaigns.Campaign');\nreturn ok({ totalCampaigns: count });"
}
]
}
```

### Endpoint Descriptor
## Endpoint Descriptor

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `name` | string | **Required** | Unique endpoint name |
| `route` | string | **Required** | URL route pattern (supports `{parameters}`) |
| `method` | string | `"GET"` | HTTP method: `GET`, `POST`, `PUT`, `DELETE` |
| `javascript` | string | **Required** | JavaScript handler code |
| `description` | string | null | Description for documentation |
| `requireAuthentication` | bool | `true` | Require authenticated user |
| `requiredPermissions` | string[] | null | Required permission names |
| `name` | string | Required | Unique endpoint identifier |
| `route` | string | Required | URL route pattern; must start with `/` and can contain `{parameters}` |
| `method` | string | `GET` | `GET`, `POST`, `PUT`, `DELETE`, or `PATCH` |
| `javascript` | string | Required | JavaScript handler code |
| `description` | string | null | Optional designer/documentation text |
| `requireAuthentication` | bool | `true` | Whether the caller must be authenticated |
| `requiredPermissions` | string[] | null | Permission names required to call the endpoint |

## Route Parameters
Permission checks require an authorized user even when `requireAuthentication` is set to `false`. Keep endpoints authenticated by default and use `requireAuthentication: false` only for intentionally public APIs without `requiredPermissions`.

Use `{paramName}` syntax in the route. Access values via the `route` object:
## Route and Request Data

Use `{paramName}` syntax for route parameters. Endpoint scripts can read request data through globals:

| Variable | Description |
|----------|-------------|
| `request` | Full request object |
| `route` | Route values, for example `route.id` |
| `params` | Alias for `route` |
| `query` | Query string values, for example `query.q` |
| `body` | Parsed request body |
| `headers` | Selected safe request headers |

```json
{
"name": "GetProductById",
"route": "/api/custom/products/{id}",
"name": "GetCampaignById",
"route": "/api/custom/campaigns/{id}",
"method": "GET",
"javascript": "var product = await db.get('LowCodeDemo.Products.Product', route.id);\nif (!product) { return notFound('Product not found'); }\nreturn ok({ id: product.Id, name: product.Name, price: product.Price });"
"javascript": "var campaign = await db.get('Acme.Campaigns.Campaign', route.id);\nif (!campaign) { return notFound('Campaign not found'); }\nreturn ok({ id: campaign.Id, name: campaign.Name });"
}
```

## JavaScript Context
For non-GET requests, `body` is parsed when a body is present and remains subject to the configured request size limit. The `headers` object intentionally contains only selected request headers such as `Content-Type`, `Accept`, `Accept-Language`, and `X-Requested-With`.

## Response Helpers

Endpoint scripts can return plain data, an endpoint response object, or one of the response helpers.

| Function | HTTP status | Response kind |
|----------|-------------|---------------|
| `ok(data, headers?)` | 200 | JSON |
| `okText(text, contentType?)` | 200 | Text |
| `okBinary(base64Data, contentType?)` | 200 | Binary from base64 |
| `created(data, headers?)` | 201 | JSON |
| `noContent()` | 204 | Empty |
| `badRequest(message)` | 400 | JSON error |
| `unauthorized(message)` | 401 | JSON error |
| `forbidden(message)` | 403 | JSON error |
| `notFound(message)` | 404 | JSON error |
| `error(message)` | 500 | JSON error |

For custom status codes or response metadata, return an object with response fields:

```javascript
return {
statusCode: 202,
kind: 'json',
data: { accepted: true },
headers: { 'x-trace-id': guid() }
};
```

Inside custom endpoint scripts, you have access to:
Response kinds are:

### Request Context
| Kind | Description |
|------|-------------|
| `json` | JSON serialization, default |
| `text` | Plain text |
| `binaryBase64` | Base64-encoded binary payload |

| Variable | Description |
|----------|-------------|
| `request` | Full request object |
| `route` | Route parameter values (e.g., `route.id`) |
| `params` | Alias for route parameters |
| `query` | Query string parameters (e.g., `query.q`, `query.page`) |
| `body` | Request body (for POST/PUT) |
| `headers` | Request headers |
| `user` | Current user (same as `context.currentUser` in [Interceptors](interceptors.md)) |
| `email` | Email sender (same as `context.emailSender` in [Interceptors](interceptors.md)) |

### Response Helpers

| Function | HTTP Status | Description |
|----------|-------------|-------------|
| `ok(data)` | 200 | Success response with data |
| `created(data)` | 201 | Created response with data |
| `noContent()` | 204 | No content response |
| `badRequest(message)` | 400 | Bad request response |
| `unauthorized(message)` | 401 | Unauthorized response |
| `forbidden(message)` | 403 | Forbidden response |
| `notFound(message)` | 404 | Not found response |
| `error(message)` | 500 | Internal server error response |
| `response(statusCode, data, error)` | Custom | Custom status code response |

### Logging

| Function | Description |
|----------|-------------|
| `log(message)` | Log an informational message |
| `logWarning(message)` | Log a warning message |
| `logError(message)` | Log an error message |
## Script Services

### Database API
Custom endpoint scripts use the same common [Scripting API](scripting-api.md) services as other low-code scripts:

The full [Scripting API](scripting-api.md) (`db` object) is available for querying and mutating data.
| Service | Example |
|---------|---------|
| `db` | Query or mutate dynamic entities |
| `user` / `currentUser` | Read current user information |
| `tenant` / `currentTenant` | Read tenant information |
| `auth` / `authorization` | Check permissions |
| `settings`, `features`, `config` | Read allowed settings, features, and app configuration |
| `http` | Call allowed outbound HTTP services |
| `email` | Send or queue email |
| `events`, `jobs` | Publish distributed events or enqueue background jobs |
| `files`, `images`, `attachments` | Work with low-code files and record attachments |
| `log`, `logWarning`, `logError` | Write logs |

## Examples

### Get Statistics
### Statistics

```json
{
"name": "GetProductStats",
"route": "/api/custom/products/stats",
"name": "GetCampaignStats",
"route": "/api/custom/campaigns/stats",
"method": "GET",
"requireAuthentication": false,
"javascript": "var totalCount = await db.count('LowCodeDemo.Products.Product');\nvar avgPrice = totalCount > 0 ? await db.query('LowCodeDemo.Products.Product').average(p => p.Price) : 0;\nreturn ok({ totalProducts: totalCount, averagePrice: avgPrice });"
"requireAuthentication": true,
"javascript": "var campaignQuery = await db.query('Acme.Campaigns.Campaign');\nvar total = await campaignQuery.count();\nvar active = await campaignQuery.where(c => c.Status === 1).count();\nreturn ok({ total: total, active: active });"
}
```

### Search with Query Parameters

```json
{
"name": "SearchCustomers",
"route": "/api/custom/customers/search",
"name": "SearchCampaigns",
"route": "/api/custom/campaigns/search",
"method": "GET",
"requireAuthentication": true,
"javascript": "var searchTerm = query.q || '';\nvar customers = await db.query('LowCodeDemo.Customers.Customer')\n .where(c => c.Name.toLowerCase().includes(searchTerm.toLowerCase()))\n .take(10)\n .toList();\nreturn ok(customers.map(c => ({ id: c.Id, name: c.Name, email: c.EmailAddress })));"
"javascript": "var q = query.q || '';\nvar campaignQuery = await db.query('Acme.Campaigns.Campaign');\nvar rows = await campaignQuery\n .where(c => c.Name.toLowerCase().includes(q.toLowerCase()))\n .take(10)\n .toList();\nreturn ok(rows.map(c => ({ id: c.Id, name: c.Name })));"
}
```

### Dashboard Summary
### Create with Validation

```json
{
"name": "GetDashboardSummary",
"route": "/api/custom/dashboard",
"method": "GET",
"name": "CreateCampaignDraft",
"route": "/api/custom/campaigns/draft",
"method": "POST",
"requireAuthentication": true,
"javascript": "var productCount = await db.count('LowCodeDemo.Products.Product');\nvar customerCount = await db.count('LowCodeDemo.Customers.Customer');\nvar orderCount = await db.count('LowCodeDemo.Orders.Order');\nreturn ok({ products: productCount, customers: customerCount, orders: orderCount, user: user.isAuthenticated ? user.userName : 'Anonymous' });"
"requiredPermissions": ["Acme.Campaigns.Create"],
"javascript": "if (!body.name) { return badRequest('Name is required.'); }\nvar record = await db.insert('Acme.Campaigns.Campaign', { Name: body.name, Status: 0 });\nreturn created({ id: record.Id, name: record.Name });"
}
```

## Authentication and Authorization
## Testing Endpoint Scripts

The Low-Code Designer endpoint editor includes **Test JavaScript**. Use it to run the current editor content without saving it.

The dry-run request editor lets you provide:

* HTTP method
* Request path
* Route values
* Query values
* Headers
* Body JSON
* Outbound HTTP mocks

Dry-run execution evaluates the endpoint descriptor, request context, script, authentication metadata, and required permissions against the current user. It returns the same response shape that a real endpoint execution would return.

Side effects are captured instead of being sent to external systems:

| Operation | Dry-run behavior |
|-----------|------------------|
| Database writes | Rolled back |
| Email send or queue | Captured under **Captured Side Effects** |
| Event publish | Captured under **Captured Side Effects** |
| Background job enqueue | Captured under **Captured Side Effects** |
| Outbound HTTP | Matched against HTTP mocks |
| File, image, and attachment operations | Captured without persisting files |

If a script calls the `http` helper and no mock matches the method and URL, the result contains a mock miss instead of sending a real HTTP request.

## Response Policy

Dynamic endpoint responses are validated by `LowCode:Scripting:EndpointResponse`.

```json
{
"LowCode": {
"Scripting": {
"EndpointResponse": {
"MaxBodyBytes": 1048576,
"AllowedContentTypes": [
"application/json",
"text/plain",
"application/octet-stream"
],
"BlockedHeaders": [
"Set-Cookie",
"Content-Length",
"Content-Type"
]
}
}
}
}
```

Default blocked headers also include hop-by-hop headers such as `Connection`, `Transfer-Encoding`, and `Upgrade`.

`Content-Type` is blocked as a custom response header. Choose the response kind or `contentType` field instead of setting a raw `Content-Type` header from script.

## Security Notes

| Setting | Behavior |
|---------|----------|
| `requireAuthentication: false` | Endpoint is publicly accessible |
| `requireAuthentication: true` | User must be authenticated |
| `requiredPermissions: ["MyApp.Products"]` | User must have the specified permissions |
* Prefer authenticated endpoints with explicit `requiredPermissions`.
* Treat endpoints with `requireAuthentication: false` and no `requiredPermissions` as public API surface.
* Keep endpoint scripts small and focused.
* Validate route, query, and body input before using it.
* Use `take()` for list queries.
* Use the configured HTTP, email, file, and response limits for untrusted integrations.

## See Also

* [Scripting API](scripting-api.md)
* [Script Actions](script-actions.md)
* [Interceptors](interceptors.md)
* [model.json Structure](model-json.md)
* [Model Descriptor Files](model-json.md)
Loading
Loading