Parsware
All articles

Parsware Platform

Publish a Custom API

A Custom API gives another system one address to call that keeps its shape while your tables change. We built one from nothing: the contract, the script behind it, the OpenAPI description it generates, a call from outside, and a second version answering beside the first.

The New Custom API panel: Visitor card, version 1, GET, route visitors/{id}, required scope modelapps.read, answers with JSON, next to the title "Publish a Custom API"

Reception has a badge printer, and the printer's software wants to ask Parsware about a visitor: their name, their company, their badge number. You could let it read the Visitor table directly, but then the day you rename a column, the printer stops. A Custom API gives it one address that keeps its shape, whatever happens to the table behind it.

We built one in the Visitors solution from Moving work from Dev to Test to Prod, in Development.

The contract

Open the solution, choose Custom APIs and press New:

The New Custom API panel: display name Visitor card, logical name nw_ visitorcard, version 1, method GET, route visitors/{id} with the address /api/nw/v1/visitors/{id} under it, required scope modelapps.read, and answers with JSON

Each field is a promise to whoever calls:

  • Version starts at 1. It's part of the address, and it's how you'll change the shape later without breaking anybody.
  • Method and Route: GET visitors/{id}. A braced segment is a path parameter. The panel shows the full address as you type: /api/nw/v1/visitors/{id}, with your publisher's prefix in it.
  • Required scope: the scope the caller's app registration must hold. modelapps.read for a call that only reads.
  • Answers with: JSON here. Text and HTML are the other two; the next article answers with a page.

Save, and the list shows the contract with the path it's served at.

The script

Select it and press Edit code. What the API does is a BS Lang script:

The code editor for Visitor card: the script reads the id, gets the visitor record, answers 404 when there is none, and sets name, company and badge as outputs

var visitor = getRecord('nw_visitor', getInput('id'));
if (isEmpty(visitor)) {
  setStatus(404);
  setOutput('error', 'No visitor with that id.');
} else {
  setOutput('name', visitor.nw_name);
  setOutput('company', visitor.nw_company);
  setOutput('badge', visitor.nw_badgenumber);
}

Look at the output names: name, company, badge. They're the contract's words, not the table's. If somebody renames nw_company next year, one line of this script changes and the printer never knows.

getRecord reads as the caller, through the same permission checks as the platform's own screens. A Custom API can never show a caller more than its security role allows.

The description it generates

Press OpenAPI on the toolbar. Parsware copies an OpenAPI description of the solution's contracts to the clipboard, generated from their definitions. Ours, cut down to the one route:

"/api/nw/v1/visitors/{id}": {
  "get": {
    "operationId": "nw_visitorcard_v1",
    "summary": "Visitor card",
    "security": [{ "parswareApp": ["modelapps.read"] }],
    "parameters": [{ "name": "id", "in": "path", "required": true,
                     "schema": { "type": "string", "format": "uuid" } }],
    "responses": {
      "200": { "content": { "application/json": { "schema": {
        "type": "object",
        "properties": { "error": {}, "name": {}, "company": {}, "badge": {} } } } } },
      "403": { "description": "The token does not carry the 'modelapps.read' scope." },
      "404": { "description": "No such API, or no such record." }
    }
  }
}

We didn't write a response schema. The outputs were read off the script, every setOutput it makes, error included. Write one only when you want to be stricter about types. The same document says how to get a token: the client-credentials flow, at /connect/token. This is the document you hand the printer's supplier.

A call from outside

The printer software is registered under App Registrations as Reception badge printer, with the modelapps.read scope and a Badge printer role that may read visitors and nothing else (a later article in this series covers registering one). It trades its client id and secret for a token, then calls:

GET /api/nw/v1/visitors/57fd0a7e…a958
Authorization: Bearer eyJhbGciOi…

200 OK
{ "name": "Ada Lovelace", "company": "Analytical Engines", "badge": "B-0142" }

And for a visitor that isn't there, the answer the script chose:

404 Not Found
{ "error": "No visitor with that id." }

Version 2, beside version 1

A year later the printer's next model wants the badge number as badgeNumber and the visitor's company as a nested object. That's a different shape, and the old printers still expect the old one. So you don't edit v1. Press New again, give it the same display name, version 2, the same route, and its own script:

The Custom APIs list: Visitor card v1 at /api/nw/v1/visitors/{id} and Visitor card v2 at /api/nw/v2/visitors/{id}, both answering

Two rows, two addresses, one contract at two ages. The old printers keep calling v1, the new ones call v2, and both answer, each in its own shape:

GET /api/nw/v1/visitors/57fd0a7e…a958
{ "name": "Ada Lovelace", "company": "Analytical Engines", "badge": "B-0142" }

GET /api/nw/v2/visitors/57fd0a7e…a958
{ "name": "Ada Lovelace", "company": { "name": "Analytical Engines" }, "badgeNumber": "B-0142" }

Open v1 and press Edit: its logical name and version can't be changed, because they're what somebody else's code is written against.

Retiring v1

When the last old printer is gone, open v1, untick Answering and Save:

The Custom APIs list: Visitor card v2 Answering, and Visitor card v1 Retired

A call to v1 now gets a 404, No Custom API answers GET /api/nw/v1/visitors/…, and the row stays: it still documents what v1 promised, and it still travels in the solution. Untick, don't delete. A deleted version is a route that vanished without notice.

It travels like everything else

A Custom API is a solution component. Export the solution and import it into Test, and the same contract answers there, at Test's address, with Test's records and Test's app registrations. Nothing about it is copied by hand.

Good to know

  • Two contracts can't share an address. A second API with the same method, route and version is refused when you save it.
  • Read, Update and Delete need an {id} in the route. It's the only place the record can come from.
  • throwValidationError is a 400 and means the caller got it wrong. setStatus(404) is the contract deciding there's nothing there. Use the one that says what happened.
  • A 403 means the caller's registration doesn't hold the required scope. Fix the registration, not the API.

Try it

Give a table of your own a GET <table>/{id} Custom API that answers two or three fields under names of your choosing. Press OpenAPI and read what it says, then add a v2 with one field renamed and check both answer.

Next: An HTML receipt from a Custom API.