Routing
FoxServer uses HELP comments in VFP methods to declare routes. There are no separate route configuration files: the declaration lives alongside the controller code.
HELP comment syntax
Each endpoint method must have a HELP comment in this format:
PROCEDURE NombreMetodo(req, res) HELP "VERBO: ruta/path [public] [as capacidad]"| Part | Description | Example |
|---|---|---|
| HTTP verb | GET, POST, PUT, PATCH or DELETE. HEAD is not declared: the engine serves it on its own, as the GET of the same route. | GET |
| Route | Path relative to the server prefix. Can contain {parameters}. | products/{id} |
| Visibility | public = no JWT required. Without this token = JWT mandatory. | public |
The "as" alias, and why it pays to use it
Without an alias, a route's capability is derived from the verb and the path, and that collides sooner than you would think: GET products and GET products/{id} derive the same capability, products.get, even though one lists and the other fetches one. And the engine has routes of its own that cannot give up their names: docs.get, openapi.json.get, mcp.post, rpc.post and server.status.*. If you collide with one of those, the one that loses is yours.
But what makes the alias worth it is not avoiding the collision: it is that the same function gets published three times. A route with a capability is served as a REST endpoint, as a JSON-RPC 2.0 method at /rpc and as an MCP tool at /mcp. You write one VFP function, and an AI agent calls it exactly as your front end does.
* Two routes that without an alias would collide on products.get
PROCEDURE pList(req, res) HELP "GET: products as products.list"
PROCEDURE pOne(req, res) HELP "GET: products/{id} as products.get"
* And the same one, called in all three ways:
* REST GET /products/42
* JSON-RPC POST /rpc {"method":"products.get","params":{"id":"42"}}
* MCP the products.get toolSupported HTTP methods
| Method | Description | Has body? | Route example |
|---|---|---|---|
GET | Retrieve data | No | GET: products public |
POST | Create resource | Yes | POST: products public |
PUT | Update (full) | Yes | PUT: products/{id} public |
PATCH | Update (partial) | Yes | PATCH: products/{id} public |
DELETE | Delete resource | No | DELETE: products/{id} public |
URL parameters
* Single parameter
PROCEDURE GetProduct(req, res) HELP "GET: products/{id} public"
LOCAL lcId
lcId = req.Params.id && /products/42 -> "42"
res.status(200).json(...)
ENDPROC
* Multiple parameters
PROCEDURE GetVariant(req, res) HELP "GET: products/{id}/variants/{vid} public"
LOCAL lcProductId, lcVariantId
lcProductId = req.Params.id && /products/42/variants/black -> "42"
lcVariantId = req.Params.vid && "black"
res.status(200).json(...)
ENDPROCQuery string
Query string parameters (?key=value) are accessed via req.query:
PROCEDURE SearchProducts(req, res) HELP "GET: products/search public"
* GET /products/search?name=Widget&minprice=10&page=2
LOCAL lcName, lnMin, lnPage
lcName = req.query.name && "Widget"
lnMin = VAL(req.query.minprice) && 10
lnPage = IIF(EMPTY(req.query.page), 1, VAL(req.query.page))
res.status(200).json(...)
ENDPROCRequest object: properties
| Property | Type | Description |
|---|---|---|
Method | String | The request verb |
uri | Object | An object, not a string: Scheme, Host, Port, AbsolutePath, PathAndQuery and AbsoluteUri |
Params | Object | Route parameters ({id} → req.Params.id), or req.GetParam("id", "") |
query | Object | Query string (?key=value), or req.GetQuery("key", default) |
Headers | Object | Headers. Read with req.GetHeader(key, default) |
RawBody | String | The unparsed body. Also req.GetRawBody() |
json | Object | The already parsed JSON body. req.HasJsonBody() says whether there is one |
ContentType | String | The request Content-Type |
AuthenticatedUser | Object | .id, .name and .role, with the token already validated |
Claims | Object | The extra claims: req.GetClaim(name, default) |
Response object: methods
| Method | Signature | Description |
|---|---|---|
status(code) | res.status(200) | Sets the HTTP code. Returns res for chaining. |
json(jsonStr) | res.json('{"ok":true}') | Sends JSON body. Sets Content-Type: application/json. |
send(text) | res.send("Hello") | Sends plain text or HTML. |
header(key, val) | res.header("X-Id","123") | Adds or overwrites a response header. |
location(url) | res.location("/api/v2") | Sets the Location header (for redirects). |
sendFile(path, disp) | res.sendFile("C:\\rep.pdf","attachment") | Sends a file. disp: "inline" or "attachment". |
* Chaining example — multiple headers + JSON response
res.status(201)
.header("X-Resource-Id", lcNewId)
.header("Cache-Control", "no-cache")
.json(THIS.ToJson(loResponse))Controller lifecycle
Each controller can define hooks that run before and after each endpoint:
DEFINE CLASS ProductsController AS ApiController OLEPUBLIC
FUNCTION BeforeEndpoint(req, res) AS BOOLEAN
SET DELETED ON
SET EXCLUSIVE OFF
USE data\products SHARED
RETURN .T. && .F. here would abort the request
ENDFUNC
PROCEDURE AfterEndpoint(req, res)
CLOSE DATABASES ALL
ENDPROC
PROCEDURE GetProducts(req, res) HELP "GET: products public"
&& Products table is already open (BeforeEndpoint)
&& It will be closed automatically (AfterEndpoint)
res.status(200).json(...)
ENDPROC
ENDDEFINEPublic vs. protected routes
Visibility is controlled with the public keyword in the HELP comment:
* PUBLIC endpoint — no JWT needed
PROCEDURE GetProducts(req, res) HELP "GET: products public"
res.status(200).json(...)
ENDPROC
* PRIVATE endpoint — JWT required (omit "public")
PROCEDURE DeleteProduct(req, res) HELP "DELETE: products/{id}"
&& Client must send: Authorization: Bearer eyJhb...
res.status(200).json(...)
ENDPROCNext: Middleware →