REST API¶
The 3forge REST server exposes a small set of endpoints under the path prefix /3forge_rest. It rides the Web component's HTTP/HTTPS port by default, so the base URL looks like https://<host>:33333/3forge_rest/… (or http://<host>:33332/3forge_rest/…). Every default endpoint except /3forge_rest/mcp is a GET request; the MCP endpoint takes a POST with a JSON-RPC body.
Enable the query endpoint first
The SQL endpoint /3forge_rest/query is not loaded by default (ami.rest.plugin.classes=*,!QUERY,!ADMIN). The informational endpoints below work out of the box, but you must add QUERY to run SQL. See Configuration.
Endpoints¶
| Endpoint | Purpose | Auth | Loaded by default |
|---|---|---|---|
/3forge_rest/query |
Run a Center command: SELECT, INSERT/UPDATE/DELETE, CALL a procedure, SHOW … |
Yes | No: add QUERY |
/3forge_rest/version |
Build and version details | Yes (admin) | Yes |
/3forge_rest/whoami |
The authenticated username and its properties | Yes | Yes |
/3forge_rest/stats |
JVM and per-component runtime statistics | No | Yes |
/3forge_rest/whatsmyip |
The caller's IP and request headers | No | Yes |
/3forge_rest/admin |
Console/admin command execution | Yes + EXECUTE permission |
No |
/3forge_rest/mcp |
JSON-RPC endpoint for AI/agent tooling | Yes | Yes |
/3forge_rest |
Lists the available endpoints | No | Yes |
/3forge_rest/mcp is a separate JSON-RPC endpoint for AI/agent tooling built on the same REST server; a query issued over it behaves differently from /3forge_rest/query, see Operating over MCP.
GET /3forge_rest/query¶
Runs one Center command supplied in the cmd parameter and returns the result.
Parameters¶
| Parameter | Default | Meaning |
|---|---|---|
cmd |
(required) | The Center command: a SELECT, an INSERT/UPDATE/DELETE, a CALL <proc>, SHOW TABLES, etc. |
db |
center |
Which Center to run against. An unknown name returns an error listing the known Centers. |
ds |
AMI |
The datasource to run against. |
timeout |
60000 |
Timeout in milliseconds. |
limit |
10000 |
Maximum rows returned. |
display |
text |
Output format: text, pipe, json, jsonRows, or jsonMaps. |
show_plan |
off | on includes the query plan / tracked events in the response. |
string_template |
off | on interprets ${…} templating in cmd instead of treating it literally. See the warning below. |
string_template=on makes cmd a template, not a literal
With string_template=on the server interpolates ${…} expressions inside the statement before running it. That is useful for trusted server-side templating, and it means a statement built from caller-supplied input becomes an injection vector: an attacker who controls any part of cmd can inject an expression that is evaluated server-side.
Leave it off, which is the default. Where it is needed, build cmd only from values your own code controls, and never interpolate request parameters, form fields, or anything else originating with the caller.
Response formats¶
The display parameter selects the shape:
text(default): a human-readable ASCII table. Good for eyeballing in a terminal, not for parsing.pipe: pipe-delimited. Each table starts with a header linetitle|rowCount|type|name|type|name…(interleaved column types and names, not a plain name header), then one line per row. Split on|.json:tablesis an array of table objects, column-oriented:{title, columns:[{name, type, values:[…]}]}.jsonRows:tablesis an array of table objects:{title, columns:[{name, type}], rows:[[…], …]}.jsonMaps:tablesis an object keyed by table name, each value a list of row objects. Easiest to consume from a program, but the keys within each row are alphabetized, not inSELECTorder, so usejson/jsonRowsif you need the declared column order.
tables is an object for jsonMaps, an array for json/jsonRows
With jsonMaps you reach the rows by table name: body["tables"]["QUOTES"]. With json and jsonRows, tables is an array of table objects: body["tables"][0]. Code that switches display must switch how it reaches the rows, or it fails with a type error.
Every JSON response also carries top-level fields:
| Field | When | Meaning |
|---|---|---|
status |
always | "ok" or "error" |
durationNanos |
always | Server execution time in nanoseconds |
rowsAffected |
always | Rows actually added or changed, not rows matched. 0 for a SELECT or DDL; 1 for an INSERT of a new key; 0 for an upsert that overwrites an existing key (even though the row changed); for UPDATE/DELETE, the number of rows whose values changed. A no-op UPDATE and one matching nothing both report 0. See Insert rows and call a procedure. |
returnValue |
always | Present on every response; null unless a CALL returned a value. A scalar SELECT does not set it: SELECT 1+1 AS two returns returnValue:null and delivers the result under tables with the key _. A procedure with no explicit return statement still populates it, with the value of the script's last evaluated statement: see Insert rows and call a procedure |
returnType |
with a non-null returnValue |
Always the literal string Object, whatever the value's actual type. A procedure returning a double, a string, and an int all report Object, so do not branch on it. The value itself is correctly typed in the JSON |
autogenKeys |
when present | Auto-generated keys from an insert |
error |
on a statement failure | {message[, atLine, atChar]}: atLine/atChar are 0-based and locate an expression parse error |
plan |
with show_plan=on |
Tracked execution events |
tables |
when the command returns rows | The result set(s), shaped per display (object for jsonMaps, array for json/jsonRows) |
Four kinds of error response¶
Errors come back in 4 different shapes, and a client must handle all of them. Authentication failures (401 and 403) are covered under Authentication.
- Statement errors: the command reached the Center but failed (unknown column, type mismatch, non-boolean
WHERE). HTTP 200, JSON body,status:"error", and anerrorobject{message, atLine, atChar}(atLine/atChar0-based). - Request errors: the request was rejected before running. HTTP 400, body is plain text or HTML with no
statusfield at all: e.g.cmd required,unknown center: …, or<i>display</i> must be either: …. - Server errors: the request timed out on the server or failed unexpectedly. HTTP 500, plain-text body:
REQUEST TIMEOUT ERRORorUNKNOWN ERROR FROM AMI REST PLUGIN, followed by a stack trace unlessami.rest.show.errors=false. - Unknown endpoint: the path names no loaded endpoint, because of a typo or because the endpoint is not enabled in
ami.rest.plugin.classes. HTTP 404, HTML body:Endpoint not found: /<name>followed by links to the loaded endpoints. It is returned before credentials are checked.
Check the HTTP status first, then the JSON status. Calling resp.json() on a 400, 404, or 500 response throws, because the body is not JSON. Note that display is case-sensitive: jsonmaps is a 400, jsonMaps is correct.
For date and time columns and their wire format, see Concepts → Creating a streamable table.
Example: query as JSON¶
With SQL that contains spaces or special characters, let curl URL-encode it:
(-k accepts a self-signed development certificate; drop it in production.)
On Windows PowerShell, pass Basic auth as a header:
Authentication¶
/3forge_rest/query requires authentication. Two mechanisms are accepted:
- HTTP Basic:
Authorization: Basic base64(user:pass), i.e.curl -u user:pass. Validated against the deployment's configured authenticator. - Web-session cookie: a browser already signed in to the Web reaches the endpoint using its existing session, with no second login.
An unauthenticated request receives HTTP 401 with a WWW-Authenticate: Basic challenge; a disabled or locked user receives 403. There are no built-in API keys or bearer tokens. The wire format is always Authorization: Basic: configuring a different ami.auth.plugin.class (OAuth/SAML/OKTA/LDAP) changes how those Basic credentials are validated, never what the client sends; the REST framework has no code path for a Bearer token. The REST framework also performs no CORS handling.
Custom endpoints¶
Beyond the built-ins, a deployment can add its own endpoint under /3forge_rest/<name> by implementing a REST plugin and loading it via ami.rest.plugin.classes. This is the right path when you want to expose a narrow, purpose-built operation instead of general SQL. Implement the com.f1.ami.amicommon.rest.AmiRestPlugin interface, verified against current source at that exact package path.
Choosing between general SQL and a custom endpoint¶
Enabling QUERY and writing a custom endpoint solve different problems, and the choice depends on how much the caller is trusted rather than on which is newer or tidier.
What enabling QUERY actually grants. The endpoint requires authentication, runs each statement as the authenticated user with that user's Center permissions applied, and records the invoking username against the request. So its reach is exactly that identity's permissions, and sizing those permissions is the control that matters. It does not add an authorization layer of its own, and it accepts any statement the Center accepts, including writes and procedure calls.
Enable QUERY when the caller is inside your trust boundary:
- A server-side integration you deploy and control, such as an ETL job or a scheduled reconciliation.
- An internal analyst or reporting tool on a controlled network.
- Ad-hoc work during development and evaluation.
Write a custom endpoint instead when any of these apply:
- The caller is less trusted, or you do not control its code.
- The operation is narrow enough to name, such as "submit an order" rather than "run SQL".
- You need authorization per operation rather than per identity, or an audit record of intent rather than of statements.
- The caller should not be able to reach tables beyond the one operation.
Restrict the endpoint's network exposure either way. Running arbitrary SQL over HTTP is reasonable inside a trusted network and rarely reasonable on an open one, which is why QUERY is excluded from the shipped default. Grant each integration its own identity with the narrowest permissions that let it work, rather than reusing an administrative account.
Related
Enabling the endpoint is covered in Configuration. The string_template parameter has its own warning above: it interpolates expressions server-side and must never receive caller-supplied input.
How a date comes back¶
A Date column renders differently per display, and neither form carries a time zone:
display |
A Date holding 2026-09-17 comes back as |
|---|---|
text (default) |
20260917, with no separators, no time, and no zone |
json, jsonRows, jsonMaps |
"2026-09-17T", a string with a trailing T and no time or zone |
A Date carries no time of day. For an instant, use a UTC (millisecond) or UTCN (nanosecond) timestamp column. These carry a zone in every format:
display |
UTC |
UTCN |
|---|---|---|
text (default) |
20260917 01:30:00.123 GMT for 13:30, a 12-hour time with no AM or PM |
The same as UTC, cut to milliseconds |
json, jsonRows, jsonMaps |
"2026-09-17T10:30:00.123Z", ISO 8601 with 3 decimal places |
"2026-09-17T10:30:00.123456789Z", ISO 8601 with 9 decimal places |
The text display does not say whether a time is before or after noon, so 01:30 can mean 01:30 or 13:30. Use a JSON display whenever the time of day matters, and for UTCN when the nanoseconds matter. Alternatively, store a Long of epoch milliseconds and convert in your client, which comes back as the plain number in every format. For the write-side rules, including the unquoted-integer trap, see Concepts.
What this does not support¶
- No CORS headers. A browser page on another origin cannot call these endpoints directly. Put a proxy in front, or call from a server-side process.
- No API keys and no OAuth. Authentication is Basic or cookie, delegated to
ami.rest.auth.plugin.class, which defaults toami.auth.plugin.class. There is no native token issuer. - No streaming. REST is request and response. For live row changes use the WebSocket streaming API.
- No subscriptions over REST, and no one-off queries over the WebSocket. A WebSocket subscription is defined by a
SELECT, but there is no call that runs a statement once and returns the result; that is what REST is for. - No
offsetparameter. To page through a large table, putLIMIT <offset>,<count>in the command, e.g:LIMIT 10000,10000. Thelimitparameter is only the default cap, applied when the statement has noLIMITof its own. - A truncated result is not flagged. A result set is capped by
limit, default10000. When more rows match, the response returns exactlylimitrows withstatus:"ok"and nothing indicating that rows were withheld: no count of matches, no truncation field, no warning. A client cannot tell a complete result from a capped one, so compare the row count you received againstlimityourself, and treat equality as "possibly truncated".