|
1 | 1 | ```json |
2 | 2 | //[doc-seo] |
3 | 3 | { |
4 | | - "Description": "Define custom REST API endpoints with JavaScript handlers in the ABP Low-Code System. Create dynamic APIs without writing C# controllers." |
| 4 | + "Description": "Define JavaScript-backed custom REST endpoints in the ABP Low-Code System without writing custom .NET controllers." |
5 | 5 | } |
6 | 6 | ``` |
7 | 7 |
|
8 | 8 | # Custom Endpoints |
9 | 9 |
|
10 | | -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. |
| 10 | +> **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. |
11 | 11 |
|
12 | | -## Defining Endpoints |
| 12 | +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. |
| 13 | + |
| 14 | +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. |
13 | 15 |
|
14 | | -Add endpoints to the `endpoints` array in `model.json`: |
| 16 | +## Defining Endpoints |
15 | 17 |
|
16 | 18 | ```json |
17 | 19 | { |
18 | 20 | "endpoints": [ |
19 | 21 | { |
20 | | - "name": "GetProductStats", |
21 | | - "route": "/api/custom/products/stats", |
| 22 | + "name": "GetCampaignStats", |
| 23 | + "route": "/api/custom/campaigns/stats", |
22 | 24 | "method": "GET", |
23 | | - "description": "Get product statistics", |
24 | | - "requireAuthentication": false, |
25 | | - "javascript": "var count = await db.count('LowCodeDemo.Products.Product');\nreturn ok({ totalProducts: count });" |
| 25 | + "description": "Get campaign statistics", |
| 26 | + "requireAuthentication": true, |
| 27 | + "requiredPermissions": ["Acme.Campaigns"], |
| 28 | + "javascript": "var count = await db.count('Acme.Campaigns.Campaign');\nreturn ok({ totalCampaigns: count });" |
26 | 29 | } |
27 | 30 | ] |
28 | 31 | } |
29 | 32 | ``` |
30 | 33 |
|
31 | | -### Endpoint Descriptor |
| 34 | +## Endpoint Descriptor |
32 | 35 |
|
33 | 36 | | Field | Type | Default | Description | |
34 | 37 | |-------|------|---------|-------------| |
35 | | -| `name` | string | **Required** | Unique endpoint name | |
36 | | -| `route` | string | **Required** | URL route pattern (supports `{parameters}`) | |
37 | | -| `method` | string | `"GET"` | HTTP method: `GET`, `POST`, `PUT`, `DELETE` | |
38 | | -| `javascript` | string | **Required** | JavaScript handler code | |
39 | | -| `description` | string | null | Description for documentation | |
40 | | -| `requireAuthentication` | bool | `true` | Require authenticated user | |
41 | | -| `requiredPermissions` | string[] | null | Required permission names | |
| 38 | +| `name` | string | Required | Unique endpoint identifier | |
| 39 | +| `route` | string | Required | URL route pattern; must start with `/` and can contain `{parameters}` | |
| 40 | +| `method` | string | `GET` | `GET`, `POST`, `PUT`, `DELETE`, or `PATCH` | |
| 41 | +| `javascript` | string | Required | JavaScript handler code | |
| 42 | +| `description` | string | null | Optional designer/documentation text | |
| 43 | +| `requireAuthentication` | bool | `true` | Whether the caller must be authenticated | |
| 44 | +| `requiredPermissions` | string[] | null | Permission names required to call the endpoint | |
42 | 45 |
|
43 | | -## Route Parameters |
| 46 | +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`. |
44 | 47 |
|
45 | | -Use `{paramName}` syntax in the route. Access values via the `route` object: |
| 48 | +## Route and Request Data |
| 49 | + |
| 50 | +Use `{paramName}` syntax for route parameters. Endpoint scripts can read request data through globals: |
| 51 | + |
| 52 | +| Variable | Description | |
| 53 | +|----------|-------------| |
| 54 | +| `request` | Full request object | |
| 55 | +| `route` | Route values, for example `route.id` | |
| 56 | +| `params` | Alias for `route` | |
| 57 | +| `query` | Query string values, for example `query.q` | |
| 58 | +| `body` | Parsed request body | |
| 59 | +| `headers` | Selected safe request headers | |
46 | 60 |
|
47 | 61 | ```json |
48 | 62 | { |
49 | | - "name": "GetProductById", |
50 | | - "route": "/api/custom/products/{id}", |
| 63 | + "name": "GetCampaignById", |
| 64 | + "route": "/api/custom/campaigns/{id}", |
51 | 65 | "method": "GET", |
52 | | - "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 });" |
| 66 | + "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 });" |
53 | 67 | } |
54 | 68 | ``` |
55 | 69 |
|
56 | | -## JavaScript Context |
| 70 | +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`. |
| 71 | + |
| 72 | +## Response Helpers |
| 73 | + |
| 74 | +Endpoint scripts can return plain data, an endpoint response object, or one of the response helpers. |
| 75 | + |
| 76 | +| Function | HTTP status | Response kind | |
| 77 | +|----------|-------------|---------------| |
| 78 | +| `ok(data, headers?)` | 200 | JSON | |
| 79 | +| `okText(text, contentType?)` | 200 | Text | |
| 80 | +| `okBinary(base64Data, contentType?)` | 200 | Binary from base64 | |
| 81 | +| `created(data, headers?)` | 201 | JSON | |
| 82 | +| `noContent()` | 204 | Empty | |
| 83 | +| `badRequest(message)` | 400 | JSON error | |
| 84 | +| `unauthorized(message)` | 401 | JSON error | |
| 85 | +| `forbidden(message)` | 403 | JSON error | |
| 86 | +| `notFound(message)` | 404 | JSON error | |
| 87 | +| `error(message)` | 500 | JSON error | |
| 88 | + |
| 89 | +For custom status codes or response metadata, return an object with response fields: |
| 90 | + |
| 91 | +```javascript |
| 92 | +return { |
| 93 | + statusCode: 202, |
| 94 | + kind: 'json', |
| 95 | + data: { accepted: true }, |
| 96 | + headers: { 'x-trace-id': guid() } |
| 97 | +}; |
| 98 | +``` |
57 | 99 |
|
58 | | -Inside custom endpoint scripts, you have access to: |
| 100 | +Response kinds are: |
59 | 101 |
|
60 | | -### Request Context |
| 102 | +| Kind | Description | |
| 103 | +|------|-------------| |
| 104 | +| `json` | JSON serialization, default | |
| 105 | +| `text` | Plain text | |
| 106 | +| `binaryBase64` | Base64-encoded binary payload | |
61 | 107 |
|
62 | | -| Variable | Description | |
63 | | -|----------|-------------| |
64 | | -| `request` | Full request object | |
65 | | -| `route` | Route parameter values (e.g., `route.id`) | |
66 | | -| `params` | Alias for route parameters | |
67 | | -| `query` | Query string parameters (e.g., `query.q`, `query.page`) | |
68 | | -| `body` | Request body (for POST/PUT) | |
69 | | -| `headers` | Request headers | |
70 | | -| `user` | Current user (same as `context.currentUser` in [Interceptors](interceptors.md)) | |
71 | | -| `email` | Email sender (same as `context.emailSender` in [Interceptors](interceptors.md)) | |
72 | | - |
73 | | -### Response Helpers |
74 | | - |
75 | | -| Function | HTTP Status | Description | |
76 | | -|----------|-------------|-------------| |
77 | | -| `ok(data)` | 200 | Success response with data | |
78 | | -| `created(data)` | 201 | Created response with data | |
79 | | -| `noContent()` | 204 | No content response | |
80 | | -| `badRequest(message)` | 400 | Bad request response | |
81 | | -| `unauthorized(message)` | 401 | Unauthorized response | |
82 | | -| `forbidden(message)` | 403 | Forbidden response | |
83 | | -| `notFound(message)` | 404 | Not found response | |
84 | | -| `error(message)` | 500 | Internal server error response | |
85 | | -| `response(statusCode, data, error)` | Custom | Custom status code response | |
86 | | - |
87 | | -### Logging |
88 | | - |
89 | | -| Function | Description | |
90 | | -|----------|-------------| |
91 | | -| `log(message)` | Log an informational message | |
92 | | -| `logWarning(message)` | Log a warning message | |
93 | | -| `logError(message)` | Log an error message | |
| 108 | +## Script Services |
94 | 109 |
|
95 | | -### Database API |
| 110 | +Custom endpoint scripts use the same common [Scripting API](scripting-api.md) services as other low-code scripts: |
96 | 111 |
|
97 | | -The full [Scripting API](scripting-api.md) (`db` object) is available for querying and mutating data. |
| 112 | +| Service | Example | |
| 113 | +|---------|---------| |
| 114 | +| `db` | Query or mutate dynamic entities | |
| 115 | +| `user` / `currentUser` | Read current user information | |
| 116 | +| `tenant` / `currentTenant` | Read tenant information | |
| 117 | +| `auth` / `authorization` | Check permissions | |
| 118 | +| `settings`, `features`, `config` | Read allowed settings, features, and app configuration | |
| 119 | +| `http` | Call allowed outbound HTTP services | |
| 120 | +| `email` | Send or queue email | |
| 121 | +| `events`, `jobs` | Publish distributed events or enqueue background jobs | |
| 122 | +| `files`, `images`, `attachments` | Work with low-code files and record attachments | |
| 123 | +| `log`, `logWarning`, `logError` | Write logs | |
98 | 124 |
|
99 | 125 | ## Examples |
100 | 126 |
|
101 | | -### Get Statistics |
| 127 | +### Statistics |
102 | 128 |
|
103 | 129 | ```json |
104 | 130 | { |
105 | | - "name": "GetProductStats", |
106 | | - "route": "/api/custom/products/stats", |
| 131 | + "name": "GetCampaignStats", |
| 132 | + "route": "/api/custom/campaigns/stats", |
107 | 133 | "method": "GET", |
108 | | - "requireAuthentication": false, |
109 | | - "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 });" |
| 134 | + "requireAuthentication": true, |
| 135 | + "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 });" |
110 | 136 | } |
111 | 137 | ``` |
112 | 138 |
|
113 | 139 | ### Search with Query Parameters |
114 | 140 |
|
115 | 141 | ```json |
116 | 142 | { |
117 | | - "name": "SearchCustomers", |
118 | | - "route": "/api/custom/customers/search", |
| 143 | + "name": "SearchCampaigns", |
| 144 | + "route": "/api/custom/campaigns/search", |
119 | 145 | "method": "GET", |
120 | 146 | "requireAuthentication": true, |
121 | | - "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 })));" |
| 147 | + "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 })));" |
122 | 148 | } |
123 | 149 | ``` |
124 | 150 |
|
125 | | -### Dashboard Summary |
| 151 | +### Create with Validation |
126 | 152 |
|
127 | 153 | ```json |
128 | 154 | { |
129 | | - "name": "GetDashboardSummary", |
130 | | - "route": "/api/custom/dashboard", |
131 | | - "method": "GET", |
| 155 | + "name": "CreateCampaignDraft", |
| 156 | + "route": "/api/custom/campaigns/draft", |
| 157 | + "method": "POST", |
132 | 158 | "requireAuthentication": true, |
133 | | - "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' });" |
| 159 | + "requiredPermissions": ["Acme.Campaigns.Create"], |
| 160 | + "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 });" |
134 | 161 | } |
135 | 162 | ``` |
136 | 163 |
|
137 | | -## Authentication and Authorization |
| 164 | +## Testing Endpoint Scripts |
| 165 | + |
| 166 | +The Low-Code Designer endpoint editor includes **Test JavaScript**. Use it to run the current editor content without saving it. |
| 167 | + |
| 168 | +The dry-run request editor lets you provide: |
| 169 | + |
| 170 | +* HTTP method |
| 171 | +* Request path |
| 172 | +* Route values |
| 173 | +* Query values |
| 174 | +* Headers |
| 175 | +* Body JSON |
| 176 | +* Outbound HTTP mocks |
| 177 | + |
| 178 | +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. |
| 179 | + |
| 180 | +Side effects are captured instead of being sent to external systems: |
| 181 | + |
| 182 | +| Operation | Dry-run behavior | |
| 183 | +|-----------|------------------| |
| 184 | +| Database writes | Rolled back | |
| 185 | +| Email send or queue | Captured under **Captured Side Effects** | |
| 186 | +| Event publish | Captured under **Captured Side Effects** | |
| 187 | +| Background job enqueue | Captured under **Captured Side Effects** | |
| 188 | +| Outbound HTTP | Matched against HTTP mocks | |
| 189 | +| File, image, and attachment operations | Captured without persisting files | |
| 190 | + |
| 191 | +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. |
| 192 | + |
| 193 | +## Response Policy |
| 194 | + |
| 195 | +Dynamic endpoint responses are validated by `LowCode:Scripting:EndpointResponse`. |
| 196 | + |
| 197 | +```json |
| 198 | +{ |
| 199 | + "LowCode": { |
| 200 | + "Scripting": { |
| 201 | + "EndpointResponse": { |
| 202 | + "MaxBodyBytes": 1048576, |
| 203 | + "AllowedContentTypes": [ |
| 204 | + "application/json", |
| 205 | + "text/plain", |
| 206 | + "application/octet-stream" |
| 207 | + ], |
| 208 | + "BlockedHeaders": [ |
| 209 | + "Set-Cookie", |
| 210 | + "Content-Length", |
| 211 | + "Content-Type" |
| 212 | + ] |
| 213 | + } |
| 214 | + } |
| 215 | + } |
| 216 | +} |
| 217 | +``` |
| 218 | + |
| 219 | +Default blocked headers also include hop-by-hop headers such as `Connection`, `Transfer-Encoding`, and `Upgrade`. |
| 220 | + |
| 221 | +`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. |
| 222 | + |
| 223 | +## Security Notes |
138 | 224 |
|
139 | | -| Setting | Behavior | |
140 | | -|---------|----------| |
141 | | -| `requireAuthentication: false` | Endpoint is publicly accessible | |
142 | | -| `requireAuthentication: true` | User must be authenticated | |
143 | | -| `requiredPermissions: ["MyApp.Products"]` | User must have the specified permissions | |
| 225 | +* Prefer authenticated endpoints with explicit `requiredPermissions`. |
| 226 | +* Treat endpoints with `requireAuthentication: false` and no `requiredPermissions` as public API surface. |
| 227 | +* Keep endpoint scripts small and focused. |
| 228 | +* Validate route, query, and body input before using it. |
| 229 | +* Use `take()` for list queries. |
| 230 | +* Use the configured HTTP, email, file, and response limits for untrusted integrations. |
144 | 231 |
|
145 | 232 | ## See Also |
146 | 233 |
|
147 | 234 | * [Scripting API](scripting-api.md) |
| 235 | +* [Script Actions](script-actions.md) |
148 | 236 | * [Interceptors](interceptors.md) |
149 | | -* [model.json Structure](model-json.md) |
| 237 | +* [Model Descriptor Files](model-json.md) |
0 commit comments