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 in cli\ and the FoxForge workshop for the IDE in ide\. 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:

A minimal endpoint
PROCEDURE pHello(req, res) HELP "GET: hello public"
    LOCAL loRes
    loRes         = THIS.newObject("message")
    loRes.message = "hello"
    RETURN THIS.Ok(loRes)
ENDPROC

That 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 json

The 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:

Items controller class
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
enddefine

That 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.

Create the sample database
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:

pItems: the whole table as 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)
ENDPROC

TableToJson() 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, not Ok. TableToJson() already returns JSON. Pass it through Ok() 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 use OkRaw() 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:

BeforeEndpoint: set up every request
FUNCTION BeforeEndpoint(req, res)
    SET DELETED ON
    SET EXCLUSIVE OFF
    USE items IN 0 SHARED AGAIN
ENDFUNC

PROCEDURE AfterEndpoint(req, res)
    CLOSE DATABASES ALL
ENDPROC

Step 4. Read what comes in: one item by its code

A second endpoint with a parameter in the route:

pItem: one item by its code
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)
ENDPROC

Mind 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

pCreate: add a new item
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)
ENDPROC

req.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 json

From 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\warehouse

You 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 json

It 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/ZZZZ
HTTP/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 Postman

If 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.