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 encli\y el taller FoxForge para el IDE enide\. 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:
PROCEDURE pHola(req, res) HELP "GET: hola public"
LOCAL loRes
loRes = THIS.newObject("mensaje")
loRes.mensaje = "hola"
RETURN THIS.Ok(loRes)
ENDPROCEso 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 jsonEl 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:
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
enddefineEse 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.
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:
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)
ENDPROCTableToJson() 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:
OkRawy noOk.TableToJson()ya devuelve JSON. Si lo pasas porOk()se serializa otra vez y llega como una cadena escapada.Ok()con una cadena no serializa nada: devuelve{}. Pásale siempre un objeto, o usaOkRaw()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:
FUNCTION BeforeEndpoint(req, res)
SET DELETED ON
SET EXCLUSIVE OFF
USE articulos IN 0 SHARED AGAIN
ENDFUNC
PROCEDURE AfterEndpoint(req, res)
CLOSE DATABASES ALL
ENDPROCPaso 4. Leer lo que entra: un artículo por su código
Un segundo endpoint con un parámetro en la ruta:
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)
ENDPROCOjo 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
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)
ENDPROCreq.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 jsonDesde 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\almacenVerá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 jsonArranca, 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/ZZZZHTTP/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 PostmanSi 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.