Publicar una API personalizada
Objetivo
Dar al sistema de otra empresa una única dirección a la que llamar
(POST /api/par/v1/invoices/{id}/approve) y que esa dirección siga funcionando igual después
de cambiar el nombre de una columna.
Por qué existe
Parsware ya tiene una API para sus tablas. Se genera a partir de los metadatos de la tabla, lo
que significa que cambia de forma cada vez que cambia la tabla. Es exactamente lo que se quiere
dentro de la plataforma, y exactamente lo que no se debe entregar a alguien de fuera: esa
persona escribió su código contra una forma, y no lo va a reescribir porque usted haya cambiado
par_total por par_amount.
Una API personalizada es la promesa de que una ruta conserva su forma mientras las tablas que hay detrás cambian.
Es un componente de su solución, como un formulario o una vista. Se crea en Desarrollo, viaja en el paquete de la solución y responde en Prueba y en Producción porque la importó allí.
Antes de empezar
- Una solución, con un editor (su prefijo pasa a formar parte de todas las rutas que publique).
- La tabla que quiere exponer, si esta API trata sobre una tabla.
- Un registro de aplicación para el sistema que le llamará, con el ámbito que va a exigir. Crearlo es tarea de un administrador: Registrar una aplicación externa.
Pasos
- Abra su solución y elija API personalizadas.
- Elija Nuevo.
- Asígnele un nombre para mostrar. El nombre lógico se rellena solo, con el prefijo de su editor.
- Deje la versión en 1. La subirá más adelante, y la versión anterior seguirá respondiendo mientras quien le llama se pone al día.
- Elija el método y escriba la ruta, sin barra inicial. Un segmento entre llaves es un
parámetro de ruta:
invoices/{id}/approve. La pantalla le muestra la dirección completa mientras escribe. - Elija el ámbito obligatorio: el ámbito que debe tener el registro de aplicación del
sistema que llama, como
modelapps.read. Es una lista, no un cuadro de texto: un ámbito que nadie puede recibir rechazaría todas las llamadas, y no lo descubriría hasta que lo hiciera un socio. - Elija con qué responde: JSON, texto o HTML. Consulte la sección siguiente; también decide lo que hace el script inicial, así que elíjalo antes de escribir código.
- El esquema de solicitud y el esquema de respuesta son opcionales. Si se dejan vacíos, el
contrato se lee de su script — cada
getInputy cadasetOutput— y se publica en el documento OpenAPI. Escriba un JSON Schema solo para ser más estricto (tipos, campos obligatorios, límites); un esquema de solicitud escrito se comprueba en cada llamada, así que una solicitud mal formada se rechaza en la puerta. - Deje marcada la casilla Responde y seleccione Guardar.
- Seleccione la fila y pulse Editar código. La lista se sustituye por el editor: aquí es donde vive el comportamiento de la API, escrito en BS Lang. Pulse el ojo para ver todas las funciones a las que puede llamar. Guardar y después Cerrar.
Con qué responde
Una API personalizada responde con una de tres cosas, y usted elige cuál en el contrato:
| Responde con | El script escribe | Adecuado para |
|---|---|---|
| JSON | setOutput("name", value), una vez por campo | Otro sistema que lee sus datos |
| Texto | setBody("...") | Una línea: una referencia, un token, una fila CSV |
| HTML | setBody("<p>...</p>") | Un correo de confirmación, una página que abre un navegador |
setOutput("reference", "BOOKING-42"); // JSON: { "reference": "BOOKING-42" }
setBody("BOOKING-42"); // text or HTML: exactly that, and nothing else
Use uno u otro, nunca los dos. Un script que llama a setOutput y a setBody se rechaza
al ejecutarse, en lugar de que uno de los dos gane sin avisar: una respuesta que no puede
explicar es peor que un error que puede leer.
Hay dos funciones más que van con ellas:
setStatus(404): el estado con el que quiere responder. Es distinto dethrowValidationError, que significa quien llama se ha equivocado (un 400);setStatuses su contrato el que decide. Un204no debe llevar cuerpo.setContentType("html"): cambia el tipo para una llamada. La mayoría de las API no lo necesitan nunca, porque el contrato ya declara con qué responden.
Por qué lo declara el contrato y no solo su código: el documento OpenAPI se genera antes de que nadie le llame, así que solo puede describir lo que dice el contrato. Declare HTML y el documento dirá HTML; déjelo en JSON y un integrador escribirá un analizador de JSON alrededor de su página.
Poner un valor guardado en una página
Una página se construye con texto, y parte de ese texto son sus registros. Pase cada valor
guardado por htmlEncode antes de ponerlo en la página:
var booking = getRecord('par_booking', getInput('id'));
setBody('<h1>Receipt ' + htmlEncode(booking.par_name) + '</h1>'
+ '<p>' + htmlEncode(booking.par_guest) + '</p>');
Así, un huésped que escribió <b>, o peor, <script> en su propio nombre aparece como ese texto,
en lugar de pasar a formar parte del recibo que abre otra persona. Un campo que nadie rellenó vuelve
vacío, de modo que deja un hueco en vez de la palabra null. Lo mismo vale para un correo que envíe
con html: true. Su propio marcado, las etiquetas que escribió en el script, no se codifica.
Responder con una lista de registros
setOutput lleva completos un registro y una página de registros:
var rows = runQuery({
entity: 'par_invoice',
select: ['par_name', 'par_total'],
filter: { connector: 'And', conditions: [{ field: 'par_status', operator: 'Equals', value: 'Open' }] }
});
setOutput("count", rows.length());
setOutput("invoices", rows); // a JSON array, one object per row
Quien llama recibe { "count": 2, "invoices": [ { "id": "...", "par_name": "INV-1", ... } ] },
con los nombres de sus campos exactamente como están en la tabla, que es lo que escribe en el
esquema de respuesta para que el documento OpenAPI lo describa.
No puede construir una lista propia. BS Lang no tiene manera de agregar elementos a una, así
que las filas con las que responde son las que le entregó una consulta. Use select para elegir
las columnas en lugar de intentar montarlas: una columna que no seleccionó es una columna que
no viaja, y así es como deja una nota interna fuera de una respuesta que está publicando.
Responder a un cliente que ha iniciado sesión
Todo lo anterior da por hecho que quien llama es un sistema: un registro de aplicación que hace lo que su rol de seguridad le permite. Un portal es distinto: parsware.com, o cualquier sitio que construya con Portal Apps, le llama en nombre de una persona que inició sesión allí, y lo único en lo que su operación no puede equivocarse es en responder sobre su empresa y la de nadie más.
Hay exactamente una manera de preguntar quién es:
| Función | Responde |
|---|---|
portalCustomerId() | el cliente (un registro de Cuenta) al que pertenece la persona que ha iniciado sesión |
portalUserId() | el propio usuario del portal |
portalContactId() | el registro de Contacto que es, o vacío si no está vinculado a ninguno |
portalEmail() | la dirección con la que inició sesión |
portalRole() | "Owner", "Billing" o "Member": lo que puede hacer en nombre de su empresa |
var customerId = portalCustomerId();
var rows = runQuery({
entity: 'par_invoice',
select: ['par_name', 'par_total'],
filter: { connector: 'And', conditions: [{ field: 'par_customer', operator: 'Equals', value: customerId }] }
});
setOutput("invoices", rows);
Nunca reciba el cliente como entrada. No es un descuido que no haya ningún
getInput("customerId") en ese script: un id de cliente que quien llama puede enviar es un id de
cliente que quien llama puede cambiar, y basta una solicitud modificada para leer las facturas de
otra persona. El valor sale de un token firmado, que nadie entre el visitante y usted puede
reescribir.
De ello se derivan varias cosas, todas deliberadas:
-
Una llamada sin una persona que haya iniciado sesión se rechaza, no se responde con nada. Si su script llama a
portalCustomerId()y la solicitud no llevaba un inicio de sesión del portal, quien llama recibe un 401, en lugar de que su script reciba un valor vacío y quizá filtre por él. -
portalRole()es la manera de reservar algo para las personas que deben verlo. El total de una factura o un método de pago es para un Propietario o para la persona que paga, no para cualquiera que tenga un inicio de sesión:if (portalRole() == "Member") {throwValidationError("Ask an Owner or a Billing contact for this.");} else {setOutput("total", rows[0].par_total);}Los tres roles son una escalera (un Propietario puede hacer todo lo que puede Facturación, y Facturación todo lo que puede un Miembro), así que comprobar el único rol que no puede suele ser más corto que enumerar los que sí pueden.
-
Lo que puede hacer el visitante es más limitado que lo que puede hacer el sitio. Las operaciones permitidas son la intersección entre los permisos de la aplicación del portal y los de la persona que ha iniciado sesión. Un sitio con permisos amplios no da poder a sus visitantes.
Rechazar detiene su script
throwValidationError termina el script en la línea en la que se llama. No se ejecuta nada
de lo que viene después: ni el resto del bloque, ni las líneas de debajo, ni un catch con el
que lo haya rodeado. Quien llama recibe un 400 con su frase, y no se escribe nada de lo que el
script habría escrito después.
Así que compruebe primero, y escriba cuando ya haya pasado las comprobaciones:
if (isEmpty(getInput("reference"))) {
throwValidationError("A reference is required.");
}
createRecord('par_invoice', { par_name: getInput("reference") });
setStatus(201);
Lo único que un rechazo no puede deshacer es una escritura que ocurrió antes: un script que crea un registro y después rechaza ha creado el registro. Ponga los rechazos por encima de las escrituras.
Registrar la hora
now() devuelve el instante actual y today() el día actual, ambos como texto que puede
escribir directamente en un campo de fecha y hora:
createRecord('par_lead', { par_name: getInput("name"), par_received_at: now() });
Use estas funciones en lugar de una hora que le haya enviado quien llama. «Cuándo llegó esto» es un hecho de su plataforma; una marca de tiempo en el cuerpo de la solicitud es algo que afirma quien llama, y puede ser la fecha que quiera. Ambas responden en UTC: el calendario del lector se aplica al mostrarla, no al guardarla.
Confirmar que ha funcionado
- La fila nueva muestra su ruta servida:
/api/par/v1/invoiceso la que haya construido. - Elija OpenAPI en la barra de herramientas. El documento generado se copia en el portapapeles: péguelo al integrador que le preguntó qué aspecto tiene su punto de conexión. Se deriva de sus definiciones, así que no puede describir algo distinto de lo que hace realmente el servidor.
- Pida al sistema que llama que lo pruebe, con un token de su registro de aplicación.
Control de versiones: la parte que merece la pena entender
La identidad de una API es su nombre y su versión juntos. par_approve_invoice v1 y v2 son
un mismo contrato en dos edades, y responden en paralelo.
Así que, cuando necesite cambiar la forma de una solicitud o de una respuesta de un modo que notaría quien llama:
- Cree una nueva API personalizada con el mismo nombre para mostrar y la versión 2. No es un duplicado: la pantalla la aceptará.
- Avise a sus socios de integración y deles tiempo.
- Cuando ya nadie llame a la v1, desmarque Responde en ella.
Ese último paso es el que la gente hace mal. Desmarque Responde; no la elimine. Una versión retirada deja de responder, pero sigue existiendo, sigue viajando en su solución y sigue documentando lo que prometía la v1. Eliminarla quita la ruta sin ningún aviso: todo lo que siga llamándola recibe un simple 404, que no se distingue de que su servicio esté roto.
Lo que una API personalizada no hace
Nunca amplía el acceso. Una operación sobre una tabla pasa exactamente por la misma puerta que usan las pantallas de la propia plataforma: el permiso de quien llama sobre esa tabla, y la directiva de datos que hay detrás, deciden lo que se devuelve. Un contrato cuidado da a un sistema externo una vista estable de sus datos, nunca una vista mayor.
Si el sistema que llama recibe menos de lo que espera, la respuesta casi siempre es el rol de seguridad que hay detrás de su registro de aplicación, no la API.
Solución de problemas
| Qué ve | Qué significa |
|---|---|
| «...ya lo responde...» al guardar | Otra API de este entorno ya reclama ese método y esa ruta en esa versión. Dos contratos no pueden compartir una dirección. |
| «...ya tiene una versión 1...» | Está volviendo a crear un contrato en lugar de sacar una versión nueva. Suba la versión. |
«...debe declarar un segmento {id}» | Leer, Actualizar y Eliminar actúan sobre un registro, y la ruta es el único sitio del que puede salir el id de ese registro. |
| «Una solicitud GET no lleva cuerpo» | Quite el esquema de solicitud o cambie el método. |
| 403 para quien llama | Su registro de aplicación no tiene el ámbito que usted exigió. |
| «llamó tanto a setOutput como a setBody» | Su script construye la respuesta dos veces. Elija una: setOutput compone un objeto JSON, setBody escribe un cuerpo de texto. |
| «estableció un cuerpo y un estado 204» | Un 204 indica a quien llama que no hay nada que leer, así que nunca ve lo que escribió. Use 200 o quite el cuerpo. |
| «Un estado HTTP está entre 100 y 599» | setStatus recibió algo que no es un estado. |
| Una página llega entre comillas | El script usó setOutput en lugar de setBody. setOutput siempre responde JSON, declare lo que declare el contrato. |
| 404 para quien llama | Ninguna API habilitada responde a ese método y esa ruta en esa versión. Compruebe Responde y compruebe el segmento de versión. |
| 401 con «necesita un usuario del portal que haya iniciado sesión» | Su script llama a portalCustomerId() y la solicitud no llevaba un inicio de sesión del portal. O quien llama es un sistema y no un portal, o la sesión del visitante ha caducado. |
| 401 con «no es válido» | Se presentó un inicio de sesión del portal y no se acepta: ha caducado o se emitió para otro portal. |
| 403 con «todavía no pertenece a ninguna» | La persona ha iniciado sesión pero no se la ha incorporado a ninguna empresa. Muéstrele eso, en lugar de una lista vacía. |
| Se creó un registro aunque rechazó la solicitud | La escritura se ejecutó antes del rechazo. throwValidationError detiene el script donde se llama, así que suba la comprobación por encima de la escritura. |