I ran a survey in the FoxPro community, on Patreon and in the WhatsApp group, with a single question: what is the hardest thing about your VFP application today. Two out of three answered the same: exposing it on the web or as an API. Not heavy processes, not artificial intelligence. Getting the data and the logic they already have into something a browser, a mobile app or an external customer can call.
This tutorial is the short answer to that question. When you finish you will have a REST API, served by FoxServer, that returns a DBF table as JSON and accepts a POST to insert a row. The business code is plain VFP 9, and you do not touch the application you already have: you publish it.
What you need
- Visual FoxPro 9 SP2 installed. FoxServer compiles your project with it.
- FoxServer installed. The installer leaves the server in
bin\, the command-line tool incli\and the FoxForge workshop for the IDE inide\. The 30-day trial license issued when you register on the portal is enough for everything here. - A PowerShell console run as administrator for the compile step. Only for that one. I explain why below.
curl, which Windows already ships, or any HTTP client.
I am going to use the command line because every step can be pasted and repeated. If you prefer the VFP IDE and its menus, there is this same tutorial step by step from the IDE, with FoxForge and every screen you are going to see.
The three things to understand before writing a line
If you only read one box in this article, make it this one.
1. Your code stays yours. It only gets published. FoxServer translates nothing and imposes no framework. A PROCEDURE of a VFP class becomes an HTTP endpoint by declaring a HELP clause:
PROCEDURE pHello(req, res) HELP "GET: hello public"
LOCAL loRes
loRes = THIS.newObject("message")
loRes.message = "hello"
RETURN THIS.Ok(loRes)
ENDPROCThat is an API. The rest of the article is detail.
2. The route does not exist until you compile. The route is born in that HELP clause, and it only reaches the server when the build hook extracts it. It is the number one cause of “I added the endpoint and I get a 404”. If you add a route and it does not show up: you have not compiled.
3. The call is synchronous. An endpoint that takes two seconds takes two seconds, and it holds its slot in the meantime. For queries that is convenient; for a forty-second process you return an identifier and do the work elsewhere.
Step 1. Create the project
Add the cli\ folder of your FoxServer installation to your PATH and open PowerShell. If you would rather not touch the PATH, stand in that folder and type .\foxserver instead of foxserver: PowerShell runs nothing from the current directory without that .\, and without it the first command answers “is not recognized as the name of a cmdlet”. From here on I write them as plain foxserver.
foxserver init --name Warehouse --path C:\projects\warehouse --port 8099 --format json{
"created": true,
"name": "Warehouse",
"path": "C:\\projects\\warehouse\\"
}That leaves the folder tree, the .pjx and the server manifest in build\. Notice that the manifest exists before compiling anything: it is what makes the query commands work from now on.
Inside src\controllers\ you will also find a DemoController.prg with seven sample endpoints (hello, users, search…). init puts it there so the project serves something from the first minute. It is not yours: when you finish this tutorial, delete it and compile again, and its routes disappear.
Step 2. A controller and its first endpoint
foxserver controller-add --project C:\projects\warehouse --name Items --format json
foxserver endpoint-add --project C:\projects\warehouse --controller Items `
--method get --route items --proc pItems --public --format jsonThe second command writes a procedure with its HELP clause into src\controllers\Items.prg and links it to the project. Open it. Inside is a VFP class that inherits from ApiController, named exactly after the --name you passed, with no suffix:
define class Items as ApiController olepublic
procedure pItems(req, res) help "GET: items public"
local loRes
loRes = this.newobject("status,data,message")
loRes.status = "ok"
loRes.data = null
loRes.message = "pItems is alive"
res.status(200).json(this.toJson(loRes))
endproc
enddefineThat sample body shows the long form: build an object, serialize it, answer with the status code you want. In the rest of the article I use the short form, THIS.Ok(object), which does the same in one line. Both coexist.
The HELP clause reads like this: "<VERB>: <route> [public]". With public, the endpoint requires no token. Without it, if you have authentication configured, a token will be needed. And a warning that saves an afternoon: the HELP clause cannot exceed 255 characters. At 256, VFP does not compile and the error says nothing about length.
Step 3. Return a whole table
Before the code, the table, because that is where everybody gets stuck and not for the reason you would guess.
Where it lives. The server resolves a USE items with no path against the project’s build\ folder, which is
where the DLL is. If the table is not there, the first request returns a 500 that at least names the exact file it
looked for (the wording is VFP’s own, so it comes out in whatever language your VFP speaks; mine is Spanish):
{
"type": "about:blank",
"title": "Internal Server Error",
"status": 500,
"detail": "Internal server error: Error: .beforeendpoint c:\\projects\\warehouse\\build\\src\\controllers\\items.prg Error en la línea 9 El archivo 'c:\\projects\\warehouse\\build\\items.dbf' no existe. 1, Error Code: 1",
"instance": "/items"
}If the table is your application’s, already created and already somewhere, open it with its full path or put a
SET PATH in the BeforeEndpoint below.
And if you are creating one to try this out, create it inside a database container, not free: description
is eleven letters long and a free table does not accept field names longer than ten (it answers with error 1712,
which talks about duplicate names and says nothing about length). And code has to fit the 32 characters that
NewGuid() returns in step 5: with less, the insert stores a truncated code and hands you back the whole one, so
the identifier the client receives does not exist in the table.
CD C:\projects\warehouse\build
CREATE DATABASE warehousedata
CREATE TABLE items (code C(32), description C(60), price N(10,2), stock N(10,0), active L)
INSERT INTO items VALUES ("A001", "M6 screw", 0.42, 1200, .T.)
INSERT INTO items VALUES ("A002", "M6 washer", 0.11, 3400, .T.)Now, this is where your application comes in. Replace the body of pItems with a query against your table and a single call that turns the cursor into JSON:
PROCEDURE pItems(req, res) HELP "GET: items public as items.list"
LOCAL lcJson
SELECT code, description, price, stock ;
FROM items ;
WHERE active ;
ORDER BY description ;
INTO CURSOR itm
lcJson = THIS.TableToJson("itm")
USE IN SELECT("itm")
RETURN THIS.OkRaw(lcJson)
ENDPROCTableToJson() is the function that saves ninety percent of the work of a query API: a cursor goes in, a JSON array comes out. Two details that are not obvious:
OkRaw, notOk.TableToJson()already returns JSON. Pass it throughOk()and it gets serialized a second time and arrives as an escaped string.Ok()with a string serializes nothing: it returns{}. Always pass it an object, or useOkRaw()if the JSON is already built.
You open the items table the way you always have. If your application has a startup routine that sets paths and SETs, the place to call it is BeforeEndpoint, which runs before every request, and AfterEndpoint to close up. For example:
FUNCTION BeforeEndpoint(req, res)
SET DELETED ON
SET EXCLUSIVE OFF
USE items IN 0 SHARED AGAIN
ENDFUNC
PROCEDURE AfterEndpoint(req, res)
CLOSE DATABASES ALL
ENDPROCStep 4. Read what comes in: one item by its code
A second endpoint with a parameter in the route:
PROCEDURE pItem(req, res) HELP "GET: items/{code} public as items.one"
LOCAL lcCode, loItem
lcCode = req.GetParam("code", "")
IF EMPTY(lcCode)
RETURN THIS.BadRequest("The item code is missing")
ENDIF
SELECT items
LOCATE FOR ALLTRIM(items.code) == ALLTRIM(lcCode)
IF !FOUND()
RETURN THIS.NotFound("There is no item " + lcCode)
ENDIF
loItem = THIS.NewObject("code,description,price,stock")
loItem.code = ALLTRIM(items.code)
loItem.description = ALLTRIM(items.description)
loItem.price = items.price
loItem.stock = items.stock
RETURN THIS.Ok(loItem)
ENDPROCMind the alias: this searches items, the table BeforeEndpoint opens, and not the itm cursor from the previous step. That cursor is born and dies inside pItems; using it here returns a 500 with “No se encuentra el alias ‘ITM’”, one of those errors that costs a while because the code reads perfectly.
{code} in the route arrives through req.GetParam(). The second argument is the default: without it, a request missing that part gives you an empty string and you find out late. The rest of what req carries: req.GetQuery("q", "") for the query string, req.GetHeader() for headers, req.json for the body and req.AuthenticatedUser for whoever logged in.
There are nine results you can return and they are named as you would expect: Ok, OkRaw, Created, NoContent, BadRequest, Unauthorized, Forbidden, NotFound and Fail(status, message, code) for anything else.
Created also takes a second argument, the URI of the resource you just created, and then the 201 goes out with a Location header: RETURN THIS.Created(loNew, "/items/" + loNew.code). With a single argument, as in step 5, the 201 goes out without it.
Step 5. Accept an insert
PROCEDURE pCreate(req, res) HELP "POST: items public as items.create"
LOCAL loData, loNew, lcDesc
loData = req.json
IF ISNULL(loData) OR VARTYPE(loData) != "O"
RETURN THIS.BadRequest("The body must be a JSON object", "invalid_body")
ENDIF
lcDesc = ""
TRY
lcDesc = ALLTRIM(TRANSFORM(loData.description))
CATCH
lcDesc = ""
ENDTRY
IF EMPTY(lcDesc)
RETURN THIS.BadRequest("The description is missing", "missing_description")
ENDIF
loNew = THIS.NewObject("code,description")
loNew.code = THIS.NewGuid()
loNew.description = lcDesc
INSERT INTO items (code, description, active) ;
VALUES (loNew.code, loNew.description, .T.)
RETURN THIS.Created(loNew)
ENDPROCreq.json brings the body already parsed. Always validate: what comes in over HTTP was not typed into your form. And one concrete trap: do not use PEMSTATUS() on req.json to check whether a field is present. It works in production and fails on the test bench, where the object travels over COM. Direct access inside a TRY, as above, works in both worlds.
Step 6. Compile. The only wall
foxserver build --project C:\projects\warehouse --format jsonFrom a normal console this is not going to work, and that is on purpose. You will see this (the CLI’s messages are in Spanish today; this one says that compiling needs an elevated console, because BUILD MTDLL registers the COM class at the end and without permissions it fails with error 1640, leaving a DLL without a type library, and that nothing has been touched):
foxserver: hace falta una consola ELEVADA para compilar.
BUILD MTDLL registra la coclase en COM al terminar, y sin
permisos falla con el error 1640 dejando una DLL sin type
library. Compila, pesa una decima parte y el motor no puede
activar nada de ella.
No se ha tocado nada. Abre una consola como administrador y
repite el comando.Open PowerShell as administrator and repeat the command. If you run it without --format json you may see a build.register warning at the end: it is not a failure, the DLL is compiled. Now the endpoints exist for the server:
foxserver endpoint-list --project C:\projects\warehouseYou will see ten routes, not three: the extra seven belong to the DemoController that init left behind.
If instead of the list you see a compile error, it is printed with file and line, taken from VFP’s .ERR. Fix and repeat.
Step 7. Serve and test
foxserver serve --project C:\projects\warehouse --format jsonIt starts, gives you back the pid and returns to the console. And now, the test:
One thing before the first curl: in PowerShell, curl is an alias for Invoke-WebRequest,
which understands neither -X nor -d. Type curl.exe, with the extension, and you get the curl program. From cmd
or from bash, plain curl is already the right one.
curl.exe http://127.0.0.1:8099/items[{
"code": "A001",
"description": "M6 screw",
"price": 0.42,
"stock": 1200
},{
"code": "A002",
"description": "M6 washer",
"price": 0.11,
"stock": 3400
}]It comes out with line breaks and ordered by description, which is what the query asks for: the screw before
the washer.
curl.exe http://127.0.0.1:8099/items/A001{
"code": "A001",
"description": "M6 screw",
"price": 0.42,
"stock": 1200
}And if you ask for one that does not exist, the NotFound from step 4 comes out like this:
curl.exe -i http://127.0.0.1:8099/items/ZZZZHTTP/1.1 404 Not Found
Content-Type: application/problem+json; charset=utf-8
{"type":"about:blank","title":"Not Found","status":404,
"detail":"There is no item ZZZZ","instance":"/items/ZZZZ",
"code":"not_found"}That is Problem Details, RFC 9457: the format ASP.NET and friends use to describe an error, and one
that Postman or a browser already know how to read. The detail is your message, exactly as you wrote it
in THIS.NotFound(...); the code is yours, if you passed one. A 200 carries no envelope at all: it
comes out the way you saw above.
And the insert. The --% is not decoration: without it PowerShell eats the quotes inside -d, the body arrives
in loose pieces and curl tries to open them as if they were URLs.
curl.exe --% -X POST http://127.0.0.1:8099/items -H "Content-Type: application/json" -d "{\"description\":\"M6 nut\"}"{
"code": "6572b2099f784487a23c37e5089595e9",
"description": "M6 nut"
}From cmd or bash, the same line without the --%.
One detail for when you want other machines to call you. The project is born with "host": "127.0.0.1" in its manifest, so from outside it does not even connect: that is not the license, it is the manifest. And once you change it to the machine’s IP you will see the second thing: if you get a 403 instead of a 200, it is not your endpoint, it is the license. A Developer license only accepts requests from the machine itself. To serve others you need a Runtime license, which is the one deployed on the customer’s server. In development you do not need it.
What you have done, and what you have not
You have published a table of your application as a REST API with three endpoints, without rewriting the logic, without switching languages and without a framework in between. What you have not done yet is put it into production: HTTPS certificate, Windows service, logs, and how to deploy a new version without taking the server down. That is the next tutorial, because it is where people who already run systems in production get stuck most often.
In the meantime, two commands that work without starting anything and are worth knowing from day one:
foxserver doctor --project C:\projects\warehouse # what it lacks and where it is exposed
foxserver openapi --project C:\projects\warehouse # your API contract, ready for Swagger or PostmanIf something here does not work as written, write to soporte@irwinrodriguez.dev with the command and the output. Every output in this article was copied from a real run, and if yours differs I want to know.