Hice una encuesta a la comunidad FoxPro, en Patreon y en el grupo de WhatsApp, con una sola pregunta: qué es lo más difícil de tu aplicación VFP hoy. Dos de cada tres respondieron lo mismo: exponerla en la web o como API. No los procesos pesados, no la inteligencia artificial. Sacar los datos y la lógica que ya tienen a algo que un navegador, una app móvil o un cliente externo puedan llamar.

Este tutorial es la respuesta corta a esa pregunta. Al terminar tendrás una API REST, servida por FoxServer, que devuelve una tabla DBF en JSON y acepta un alta por POST. El código de negocio es VFP 9 normal y corriente, y no tocas la aplicación que ya tienes: la publicas.

Lo que necesitas

  • Visual FoxPro 9 SP2 instalado. FoxServer compila tu proyecto con él.
  • FoxServer instalado. El instalador deja el servidor en bin\, la herramienta de línea de comandos en cli\ y el taller FoxForge para el IDE en ide\. Con la licencia de prueba de 30 días que se emite al registrarse en el portal es suficiente para todo lo que hay aquí.
  • Una consola de PowerShell como administrador para el paso de compilar. Solo para ese. Más abajo explico por qué.
  • curl, que Windows ya trae, o cualquier cliente HTTP.

Voy a usar la línea de comandos porque cada paso se puede pegar y repetir. Si prefieres el IDE de VFP y sus menús, tienes este mismo tutorial paso a paso desde el IDE, con FoxForge y cada pantalla que vas a ver.

Las tres cosas que hay que entender antes de escribir una línea

Si solo lees un recuadro de este artículo, que sea este.

1. Tu código sigue siendo tuyo. Solo se publica. FoxServer no traduce nada ni te impone un framework. Una PROCEDURE de una clase VFP se convierte en un endpoint HTTP declarando una cláusula HELP:

Un endpoint mínimo
PROCEDURE pHola(req, res) HELP "GET: hola public"
    LOCAL loRes
    loRes         = THIS.newObject("mensaje")
    loRes.mensaje = "hola"
    RETURN THIS.Ok(loRes)
ENDPROC

Eso es una API. El resto del artículo es detalle.

2. La ruta no existe hasta que compilas. La ruta nace en esa cláusula HELP, y solo llega al servidor cuando el hook la extrae durante el build. Es la causa número uno de «he añadido el endpoint y me da 404». Si añades una ruta y no aparece: falta compilar.

3. La llamada es síncrona. Un endpoint que tarda dos segundos, tarda dos segundos, y mientras tanto ocupa su sitio. Para consultas es cómodo; para un proceso de cuarenta segundos lo que se devuelve es un identificador y el trabajo se hace por otro lado.

Paso 1. Crear el proyecto

Añade al PATH la carpeta cli\ de tu instalación de FoxServer y abre PowerShell. Si prefieres no tocar el PATH, sitúate en esa carpeta y escribe .\foxserver en lugar de foxserver: PowerShell no ejecuta nada del directorio actual sin ese .\, y si no lo pones el primer comando te contesta «no se reconoce como nombre de un cmdlet». De aquí abajo los escribo con foxserver a secas.

foxserver init --name Almacen --path C:\proyectos\almacen --port 8099 --format json
{
  "created": true,
  "name": "Almacen",
  "path": "C:\\proyectos\\almacen\\"
}

Eso deja el árbol de carpetas, el .pjx y el manifiesto del servidor en build\. Fíjate en que el manifiesto existe antes de compilar nada: es lo que hace que los comandos de consulta funcionen desde ya.

Dentro de src\controllers\ encontrarás también un DemoController.prg con siete endpoints de ejemplo (hello, users, search…). Lo pone init para que el proyecto sirva algo desde el primer momento. No es tuyo: cuando termines este tutorial, bórralo y vuelve a compilar, y sus rutas desaparecen.

Paso 2. Un controlador y su primer endpoint

foxserver controller-add --project C:\proyectos\almacen --name Articulos --format json
foxserver endpoint-add   --project C:\proyectos\almacen --controller Articulos `
                         --method get --route articulos --proc pArticulos --public --format json

El segundo comando escribe en src\controllers\Articulos.prg un procedimiento con su cláusula HELP y lo deja enlazado al proyecto. Ábrelo. Lo que hay dentro es una clase VFP que hereda de ApiController, y que se llama igual que el --name que le pasaste, sin sufijos:

Clase controladora de Artículos
define class Articulos as ApiController olepublic

    procedure pArticulos(req, res) help "GET: articulos public"
        local loRes
        loRes = this.newobject("status,data,message")
        loRes.status = "ok"
        loRes.data = null
        loRes.message = "pArticulos is alive"

        res.status(200).json(this.toJson(loRes))
    endproc
enddefine

Ese cuerpo de ejemplo enseña la forma larga: montas un objeto, lo serializas y respondes con el código que quieras. En el resto del artículo uso la forma corta, THIS.Ok(objeto), que hace lo mismo en una línea. Las dos conviven.

La cláusula HELP se lee así: "<VERBO>: <ruta> [public]". Con public, el endpoint no exige token. Sin él, si tienes autenticación configurada, hará falta uno. Y un aviso que ahorra una tarde: la cláusula HELP no puede pasar de 255 caracteres. A 256, VFP no compila y el error no menciona la longitud por ningún lado.

Paso 3. Devolver una tabla entera

Antes del código, la tabla, porque es donde se atasca todo el mundo y no por donde parece.

Dónde vive. El servidor resuelve un USE articulos sin ruta contra la carpeta build\ del proyecto, que es donde está la DLL. Si la tabla no está ahí, la primera petición devuelve un 500 que al menos dice el fichero exacto que ha buscado:

{
  "type": "about:blank",
  "title": "Internal Server Error",
  "status": 500,
  "detail": "Internal server error: Error: .beforeendpoint c:\\proyectos\\almacen\\build\\src\\controllers\\articulos.prg Error en la línea 9 El archivo 'c:\\proyectos\\almacen\\build\\articulos.dbf' no existe. 1, Error Code: 1",
  "instance": "/articulos"
}

Si la tabla es la de tu aplicación, que ya existe y está donde está, ábrela con su ruta completa o pon un SET PATH en el BeforeEndpoint de aquí abajo.

Y si la vas a crear para probar, créala dentro de una base de datos, no suelta: descripcion tiene once letras y una tabla libre no admite nombres de campo de más de diez (te contesta con un error 1712 que habla de nombres duplicados y no menciona la longitud por ningún lado). Y codigo tiene que dar para los 32 caracteres que devuelve NewGuid() en el paso 5: con menos, el alta guarda un código recortado y te devuelve el entero, así que el identificador que recibe el cliente no existe en la tabla.

Crear la base de datos de ejemplo
CD C:\proyectos\almacen\build
CREATE DATABASE almacendata
CREATE TABLE articulos (codigo C(32), descripcion C(60), precio N(10,2), stock N(10,0), activo L)
INSERT INTO articulos VALUES ("A001", "Tornillo M6", 0.42, 1200, .T.)
INSERT INTO articulos VALUES ("A002", "Arandela M6", 0.11, 3400, .T.)

Ahora sí, aquí es donde entra tu aplicación. Sustituye el cuerpo de pArticulos por una consulta a tu tabla y una sola llamada que convierte el cursor en JSON:

pArticulos: la tabla entera en JSON
PROCEDURE pArticulos(req, res) HELP "GET: articulos public as articulos.lista"
    LOCAL lcJson

    SELECT codigo, descripcion, precio, stock ;
        FROM articulos ;
        WHERE activo ;
        ORDER BY descripcion ;
        INTO CURSOR art

    lcJson = THIS.TableToJson("art")
    USE IN SELECT("art")

    RETURN THIS.OkRaw(lcJson)
ENDPROC

TableToJson() es la función que ahorra el noventa por ciento del trabajo de una API de consulta: un cursor entra, un array JSON sale. Dos detalles que no son obvios:

  • OkRaw y no Ok. TableToJson() ya devuelve JSON. Si lo pasas por Ok() se serializa otra vez y llega como una cadena escapada.
  • Ok() con una cadena no serializa nada: devuelve {}. Pásale siempre un objeto, o usa OkRaw() si el JSON ya está montado.

La tabla articulos la abres como siempre la has abierto. Si tu aplicación tiene un procedimiento de arranque que configura rutas y SETs, el sitio para llamarlo es BeforeEndpoint, que se ejecuta antes de cada petición, y AfterEndpoint para cerrar. Por ejemplo:

BeforeEndpoint: preparar cada petición
FUNCTION BeforeEndpoint(req, res)
    SET DELETED ON
    SET EXCLUSIVE OFF
    USE articulos IN 0 SHARED AGAIN
ENDFUNC

PROCEDURE AfterEndpoint(req, res)
    CLOSE DATABASES ALL
ENDPROC

Paso 4. Leer lo que entra: un artículo por su código

Un segundo endpoint con un parámetro en la ruta:

pArticulo: un artículo por su código
PROCEDURE pArticulo(req, res) HELP "GET: articulos/{codigo} public as articulos.uno"
    LOCAL lcCodigo, loArt

    lcCodigo = req.GetParam("codigo", "")
    IF EMPTY(lcCodigo)
        RETURN THIS.BadRequest("Falta el código del artículo")
    ENDIF

    SELECT articulos
    LOCATE FOR ALLTRIM(articulos.codigo) == ALLTRIM(lcCodigo)
    IF !FOUND()
        RETURN THIS.NotFound("No existe el artículo " + lcCodigo)
    ENDIF

    loArt = THIS.NewObject("codigo,descripcion,precio,stock")
    loArt.codigo      = ALLTRIM(articulos.codigo)
    loArt.descripcion = ALLTRIM(articulos.descripcion)
    loArt.precio      = articulos.precio
    loArt.stock       = articulos.stock

    RETURN THIS.Ok(loArt)
ENDPROC

Ojo al alias: aquí se busca sobre articulos, la tabla que abre BeforeEndpoint, y no sobre el cursor art del paso anterior. Ese cursor nace y muere dentro de pArticulos; usarlo aquí devuelve un 500 con «No se encuentra el alias ‘ART’», que es de los errores que cuestan un rato porque el código se lee perfectamente.

{codigo} en la ruta llega por req.GetParam(). El segundo argumento es el valor por defecto: sin él, una petición sin ese trozo te devuelve cadena vacía y te enteras tarde. Lo demás que trae req: req.GetQuery("q", "") para la query string, req.GetHeader() para cabeceras, req.json para el cuerpo y req.AuthenticatedUser para quien ha entrado.

Los resultados que puedes devolver son nueve y se llaman como esperas: Ok, OkRaw, Created, NoContent, BadRequest, Unauthorized, Forbidden, NotFound y Fail(status, mensaje, codigo) para cualquier otro.

Created admite además un segundo argumento, la URI del recurso que acabas de crear, y entonces el 201 sale con la cabecera Location: RETURN THIS.Created(loNuevo, "/articulos/" + loNuevo.codigo). Con un solo argumento, como en el paso 5, sale el 201 sin ella.

Paso 5. Recibir un alta

pAlta: dar de alta un artículo
PROCEDURE pAlta(req, res) HELP "POST: articulos public as articulos.alta"
    LOCAL loDatos, loNuevo, lcDesc

    loDatos = req.json
    IF ISNULL(loDatos) OR VARTYPE(loDatos) != "O"
        RETURN THIS.BadRequest("El cuerpo tiene que ser un objeto JSON", "cuerpo_invalido")
    ENDIF

    lcDesc = ""
    TRY
        lcDesc = ALLTRIM(TRANSFORM(loDatos.descripcion))
    CATCH
        lcDesc = ""
    ENDTRY

    IF EMPTY(lcDesc)
        RETURN THIS.BadRequest("Falta la descripción", "falta_descripcion")
    ENDIF

    loNuevo = THIS.NewObject("codigo,descripcion")
    loNuevo.codigo      = THIS.NewGuid()
    loNuevo.descripcion = lcDesc

    INSERT INTO articulos (codigo, descripcion, activo) ;
        VALUES (loNuevo.codigo, loNuevo.descripcion, .T.)

    RETURN THIS.Created(loNuevo)
ENDPROC

req.json trae el cuerpo ya parseado. Valida siempre: lo que entra por HTTP no lo ha escrito tu formulario. Y una trampa concreta: no uses PEMSTATUS() sobre req.json para comprobar si viene un campo. Funciona en producción y falla en el banco de pruebas, donde el objeto viaja por COM. El acceso directo dentro de un TRY, como arriba, funciona en los dos sitios.

Paso 6. Compilar. La única pared

foxserver build --project C:\proyectos\almacen --format json

Desde una consola normal esto no va a funcionar, y es a propósito. Verás esto:

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.

Abre PowerShell como administrador y repite el comando. Si lo lanzas sin --format json puede aparecer al final un aviso build.register: no es un fallo, la DLL está compilada. Ahora sí, los endpoints existen para el servidor:

foxserver endpoint-list --project C:\proyectos\almacen

Verás diez rutas, no tres: las siete de más son las del DemoController que dejó init.

Si en vez de la lista ves un error de compilación, sale por pantalla con fichero y línea, sacado del .ERR de VFP. Corriges y repites.

Paso 7. Servir y probar

foxserver serve --project C:\proyectos\almacen --format json

Arranca, te devuelve el pid y vuelve a la consola. Y ahora, la prueba:

Una cosa antes del primer curl: en PowerShell, curl es un alias de Invoke-WebRequest, que no entiende ni -X ni -d. Escribe curl.exe, con su extensión, y llamas al programa curl. Desde cmd o desde bash, curl a secas ya es el bueno.

curl.exe http://127.0.0.1:8099/articulos
[{
  "codigo": "A002",
  "descripcion": "Arandela M6",
  "precio": 0.11,
  "stock": 3400
},{
  "codigo": "A001",
  "descripcion": "Tornillo M6",
  "precio": 0.42,
  "stock": 1200
}]

Sale con saltos de línea y ordenado por descripcion, que es lo que pide la consulta: «Arandela» antes que «Tornillo».

curl.exe http://127.0.0.1:8099/articulos/A001
{
  "codigo": "A001",
  "descripcion": "Tornillo M6",
  "precio": 0.42,
  "stock": 1200
}

Y si pides uno que no existe, el NotFound del paso 4 sale así:

curl.exe -i http://127.0.0.1:8099/articulos/ZZZZ
HTTP/1.1 404 Not Found
Content-Type: application/problem+json; charset=utf-8

{"type":"about:blank","title":"Not Found","status":404,
 "detail":"No existe el artículo ZZZZ","instance":"/articulos/ZZZZ",
 "code":"not_found"}

Eso es Problem Details, la RFC 9457: el formato con el que ASP.NET y compañía describen un error, y que Postman o un navegador ya saben leer. El detail es tu mensaje, tal como lo escribiste en THIS.NotFound(...); el code es el tuyo, si se lo pasaste. Los 200 no llevan sobre ninguno: salen como los has visto arriba.

Y el alta. El --% no es decoración: sin él, PowerShell se come las comillas de dentro del -d, el cuerpo llega partido en trozos sueltos y curl intenta abrirlos como si fueran URLs.

curl.exe --% -X POST http://127.0.0.1:8099/articulos -H "Content-Type: application/json" -d "{\"descripcion\":\"Tuerca M6\"}"
{
  "codigo": "6572b2099f784487a23c37e5089595e9",
  "descripcion": "Tuerca M6"
}

Desde cmd o bash, la misma línea sin el --%.

Un detalle para cuando quieras que te llamen desde otra máquina. El proyecto nace con "host": "127.0.0.1" en su manifiesto, así que desde fuera ni siquiera conecta: eso no es la licencia, es el manifiesto. Y cuando lo cambies a la IP de la máquina, verás lo segundo: si en vez de 200 te devuelve 403, no es tu endpoint, es la licencia. Una licencia Developer solo acepta peticiones desde la propia máquina. Para servir a otras hace falta una licencia Runtime, que es la que se despliega en el servidor del cliente. En desarrollo no la necesitas.

Lo que has hecho, y lo que no

Has publicado una tabla de tu aplicación como API REST con tres endpoints, sin reescribir la lógica, sin cambiar de lenguaje y sin un framework por medio. Lo que no has hecho todavía es ponerla en producción: certificado HTTPS, servicio de Windows, logs, y cómo desplegar una versión nueva sin tirar el servidor. Eso es el siguiente tutorial, porque es donde más se atasca la gente que ya tiene sistemas funcionando.

Mientras tanto, dos comandos que trabajan sin levantar nada y que conviene conocer desde el primer día:

foxserver doctor  --project C:\proyectos\almacen    # qué le falta y por dónde se expone
foxserver openapi --project C:\proyectos\almacen    # el contrato de tu API, listo para Swagger o Postman

Si algo de aquí no te sale como está escrito, escríbeme a soporte@irwinrodriguez.dev con el comando y la salida. Cada salida de este artículo está copiada de una ejecución real, y si la tuya es distinta quiero saberlo.