PBToolboxAI v5 ← Site

restclient — n_pbt_restclient #

← Component reference · Guide contents

An HTTPS client for PowerBuilder, version 10 included: GET, POST, PUT, PATCH, DELETE, headers, a JSON body, a file download. Native — the system's TLS, proxy and decompression, and no CORS wall, because the request does not leave from a page.

▶ See it live — Demo application, REST client tile: the answers, the code that gets them and this page, side by side (Internet connection required).


At a glance #

Nonvisual objectn_pbt_restclient
Used forCalling a REST API from a PowerBuilder application: read, create, update, download
PrincipleA request is a call that returns the HTTP status; the answer waits in the object, read by of_response_text or of_json_value
DependencyWinHTTP, inside the library's DLL — no page, no extra runtime

Quick start #

// Local variables
n_pbt_restclient lnv_rest
n_pbt_json lnv_j
string ls_token, ls_customer
long ll_status

// One client for the API : its address and the token it sends (here read from the settings)
lnv_rest = create n_pbt_restclient
lnv_rest.is_base_url = "https://api.example.com"
ls_token = ProfileString("app.ini", "api", "token", "")
lnv_rest.of_set_bearer(/*token*/ ls_token)

// Read : the status comes back, the body waits in the object
ll_status = lnv_rest.of_get(/*url*/ "/orders/4152")
if ll_status = 200 then ls_customer = lnv_rest.of_json_value(/*path*/ "customer")

// Create : the body is built with n_pbt_json, never by hand
lnv_j.of_set_number(/*path*/ "order", /*value*/ 4152)
lnv_j.of_set_string(/*path*/ "status", /*value*/ "shipped")
ll_status = lnv_rest.of_post(/*url*/ "/shipments", /*body*/ lnv_j.of_text())
if ll_status < 0 then MessageBox("API", lnv_rest.of_get_last_error())

// The client is no longer needed
destroy lnv_rest

Every HTTP method returns the status the server chose — a 404 is an answer, not an error — or a negative code when the request did not go through: -5 invalid URL or header, client certificate not found, -4 failure (no network, unknown name, timeout, TLS, a text body over 64 MB — 16 MB in a 32-bit process; of_get_last_error() says which), -6 refused in demo mode. -2 says the client could not be created. An object made by create is enough for synchronous calls; for the events of the async calls, see "Where to write the events", below.


Why native, and not the page #

A fetch() launched from a page of the library is subject to CORS: every API that does not send Access-Control-Allow-Origin is closed to it, and enterprise APIs do not send it. So the request leaves from the DLL, through WinHTTP: the system's TLS and certificates, the configured proxy, gzip. What you lose: nothing; what you gain: every API.


Properties #

PropertyTypeDefaultRole
is_base_urlstring""Put in front of a relative URL: is_base_url = "https://api.example.com" then of_get("/orders/4152"). An absolute URL is used as it is
il_timeout_mslong30000How long each step of a request may take: resolving the name, connecting, sending, then waiting for each block of the answer. A large download that keeps moving is never cut; a server silent for that long is: -4 and "timed out". 0 or less means the default, 30 s: there is no "no timeout"
il_statuslong0The HTTP status of the last synchronous request, or its negative code; the value the request returned. An async answer has its own: of_status(id)
ib_keep_cookiesbooleantrueKeeps the cookies between requests — synchronous and async ones, which share one session: an API that sets a cookie on login keeps you logged in. At false, no cookie is sent or kept any more, and the ones kept so far are forgotten; applied at once
il_max_retrieslong0How many times an async request is tried again on 429, 503 or a network failure. A POST whose answer was lost is never tried again: it may have been processed. A failure that would happen again identically is never replayed: too many redirections, an invalid redirection, an HTTPS → HTTP redirection refused, a TLS failure, a file that cannot be written
il_retry_backoff_mslong500The wait before the first retry, in milliseconds, doubled at each try and capped at 60 s. A Retry-After header from the server (seconds or a date) takes precedence
ib_windows_authbooleanfalseIntegrated Windows authentication (Negotiate / NTLM): when a server asks for it — an intranet IIS API —, the credentials of the Windows session answer, with nothing typed. Off by default: without it they are never sent, to any server. Read at every request; only the host and port of the request answer it: a redirection to another host goes on without them
is_client_certificatestring""A client certificate (mutual TLS): the SHA-1 thumbprint of a certificate of the personal store — the user's, then the machine's —, as the certificate manager shows it; spaces and colons ignored. Empty = none. A thumbprint not found makes every request return -5 ("client certificate not found"). Read at every request. There is no option to ignore a server certificate error; it is shown to the host and port of the request only, never after a redirection elsewhere
ib_check_revocationbooleanfalseRefuses a server certificate its authority has revoked: the revocation list or OCSP of the chain is checked. Off by default: when the check cannot be made — the authority out of reach from a closed network —, the request fails (-4). Read at every request
is_last_requeststring""What the last request asked for, to read when an API answers 400: the method and the URL, then the headers your code set — their secrets masked: the scheme of an Authorization stays (Bearer ***), the value of a header whose name says key, token, secret, password or cookie goes —, then the size of the body. The library adds User-Agent, Content-Type and the cookies
ipo_ownerpowerobjectnullThe visual object this client works for: the licence is checked on its class. Needed only in the demonstration application; a development or runtime key unlocks the client without it. Not unlocked, the client runs in demo mode — bodies cut at 4096 characters, no download and no upload

Constants: METHOD_GET, METHOD_POST, METHOD_PUT, METHOD_PATCH, METHOD_DELETE for of_request.


Methods #

MethodRole
of_get_last_error ( ) → stringWhy the last synchronous request — or an async call refused at once — returned a negative code. Empty after a request that reached the server — a 404 is not an error. An async failure says why in ue_failed; a header refused when it is set (of_set_header) says why there too, and it is emptied when an async request leaves
of_set_header (string as_name, string as_value) → longA header sent with every request from now on; a name already set is replaced, an empty value removes the header. Content-Type is added by itself when a body leaves without one (JSON, UTF-8); for an upload, the one set here is ignored (multipart/form-data is set by the library). None of these headers follows a redirection to another host. Returns 0, or -5 — nothing changes — for an empty name, a name holding a space, a colon or a control character, or a value holding a line break; "another host" means another origin: another host, port or scheme — an http:// URL redirected to https:// loses its token. A value travels in Latin-1: a character beyond it (Greek, Japanese…) is refused (-5) when set, of_get_last_error() says why. The User-Agent (PBToolboxAI/<version>) is replaced here
of_remove_header (string as_name)Forgets one header, by name (case does not matter)
of_clear_headers ( )Forgets every header, the authentication included
of_set_bearer (string as_token) → longAuthorization: Bearer on every request. One authentication at a time: it replaces a Basic one; an empty token removes the authentication. Returns 0, or -5 for a token holding a line break — no authentication is then left, never half of one; it also replaces an OAuth token (of_oauth_client_credentials)
of_set_basic (string as_user, string as_password) → longBasic authentication; the DLL encodes the base64 itself — PowerBuilder 10 has none. It replaces a bearer token; an empty user removes the authentication. Returns 0, or -5 for a user or a password holding a line break — no authentication is then left. Any other character is allowed, accents and Japanese included: the pair leaves as the base64 of its UTF-8. Base64 protects nothing: over https:// TLS hides the password, over http:// it travels readable
of_oauth_client_credentials (string as_token_url, string as_client, string as_secret, string as_scope) → longOAuth2 "client credentials": asks the token endpoint for a token now — a form POST of grant_type, client_id, client_secret and scope (empty = none) —, then sends it as Authorization: Bearer on every request. The token is asked again by itself a minute before it expires (expires_in) and after a 401: a synchronous request is then replayed once, an async one gets its 401 and the next request renews the token. The token request never carries the object's own authentication. One authentication at a time: of_set_bearer, of_set_basic, of_clear_headers and of_reset stop it. Returns 0 once the token is set, -5 for an empty URL or client, -4 when the endpoint refuses or does not answer — of_get_last_error() says why, with the server's error_description —, -2 when the client could not be created. A failure keeps the settings: the next request asks again
of_get (string as_url) → longGET. Returns the HTTP status, or -5 invalid URL or header, client certificate not found, -4 failure, -6 refused in demo. The #… fragment of a URL is never sent to the server. -5 also for credentials written in the URL (https://user:pwd@…) — use of_set_basic —, -2 when the client could not be created. The query of the URL goes as written: build it with of_url as soon as a value comes from user input
of_post (string as_url, string as_body) → longPOST with a body (JSON by default, built with n_pbt_json). Returns the HTTP status, or -5 / -4 / -6
of_post_form (string as_url, string as_names[], string as_values[]) → longPOST of an HTML form (application/x-www-form-urlencoded), every name and value encoded: what an OAuth2 token endpoint, a legacy API or a web form expects. An empty name is skipped, a missing value is empty. Returns the HTTP status, or -5 / -4 / -6 / -2 like of_post
of_put (string as_url, string as_body) → longPUT with a body. Returns the HTTP status, or -5 / -4 / -6
of_patch (string as_url, string as_body) → longPATCH with a body. Returns the HTTP status, or -5 / -4 / -6
of_delete (string as_url) → longDELETE. Returns the HTTP status, or -5 / -4 / -6
of_request (string as_method, string as_url, string as_body {, string as_headers}) → longAny method (METHOD_*, or your own), any body sent as UTF-8: what the five shortcuts call. Returns the HTTP status, or -5 / -4 / -6. as_headers: headers for this request only — Name: value lines joined by ~r~n (an Idempotency-Key, an If-Match, another token); they replace a header of the same name, an empty value leaves one out, nothing is kept for the next request; an invalid line returns -5 and nothing leaves. A malformed method (GE T) returns -5
of_download (string as_url, string as_path) → longGET straight into a file, bytes untouched, whatever the size. Returns the HTTP status, or -5 invalid URL or empty path, -4 failure, -6 refused in demo. A relative path is resolved at the call in the application's current folder when the library was loaded — never where a DirList or a file dialog has moved it since
of_retrieve (datastore ads_target, string as_url {, string as_path}) → longGET of a JSON body and the array of objects found at as_path put into a DataStore ("" = the body is the array, "data/items" = an array inside it) — like Retrieve: the DataStore is emptied first, then one row per object, each member in the column of the same name (case ignored), converted to its type (n_pbt_json.of_to_datastore); the rows come in not modified. The same overloads exist for a datawindow and a datawindowchild (a drop-down list filled from an API). Returns the number of rows, or -4 when the answer is not a 2xx, is not JSON — in demo mode a body is cut at 4096 characters — or holds no array there (the DataStore then keeps its rows), -5 for an invalid DataStore or URL, and the codes of of_get; of_get_last_error() says why
of_response_text ( ) → stringThe body of the last synchronous response of this object — another client does not overwrite it —, as text: decoded from the charset the server announced (UTF-8, and the legacy sets: windows-125x, iso-8859-x, shift_jis, gbk/gb2312, big5, euc-kr, koi8-r…), UTF-8 by default (an invalid byte becomes a replacement character). Empty after a failure, and after of_download: that body is in the file. A text body is capped at 64 MB, 16 MB in a 32-bit process: beyond, -4 "body too large" — use of_download
of_response_headers ( ) → stringEvery header of the last synchronous answer, one per line
of_response_header (string as_name) → stringOne header of the last synchronous answer, by name; empty when it was not sent; a header sent several times gives all its values, joined by , — Set-Cookie by a line break (a cookie date holds a comma)
of_json_value (string as_path) → stringOne value of the JSON body by its path — keys joined by /, array positions from 1, the n_pbt_json notation: "customer", "json/customer", "items/1/qty". A string comes back decoded, a number or true/false as written ("4152"), null empty, an object or an array as JSON; empty when the path does not exist. For more than one value, load of_response_text into an n_pbt_json. The body is parsed once per response: reading ten values costs a single parse
of_url (string as_path, string as_names[], string as_values[]) → stringA URL with its query, every name and value encoded: with q = "C# & R&D" and page = "2", of_url("/items", …) returns /items?q=C%23%20%26%20R%26D&page=2. A path that already holds a query gets the pairs after a &, a fragment (#top) stays at the end, an empty name is skipped. Nothing is kept: safe between two async requests. The library never encodes the query of a URL you write yourself — a # cuts it, a & adds a parameter, a + becomes a space
of_url_encode (string as_text) → stringOne value made safe for a URL (RFC 3986, UTF-8): letters, digits, -, ., _ and ~ stay, everything else becomes %XX — "C# & co" returns C%23%20%26%20co. Empty for an empty or NULL text
of_reset ( ) → longBack to the defaults: no header, no base URL, the default timeout, no retry, cookies kept; the client is closed — its cookies go, its requests in flight are cancelled without any event — and the last answer is forgotten. Windows authentication is switched off (ib_windows_auth = false) and the client certificate forgotten (is_client_certificate = ""); ib_check_revocation goes back to false, an OAuth token is forgotten and is_last_request emptied Returns 0 once done.

Examples #

Reading a list and walking it #

// Local variables
string ls_body, ls_id
n_pbt_json lnv_list
long ll_i

// One value : of_json_value("items/1/id"). The whole list : load the body into an
// n_pbt_json and walk it by path.
if lnv_rest.of_get(/*url*/ "/orders?status=open") = 200 then
	ls_body = lnv_rest.of_response_text()
	lnv_list.of_load(/*json*/ ls_body)
	for ll_i = 1 to lnv_list.of_count(/*path*/ "items")
		ls_id = lnv_list.of_get_string(/*path*/ "items/" + String(ll_i) + "/id")
	next
end if

Downloading a document #

// Local variables
long ll_status

// Download the invoice to a file, then open it
ll_status = lnv_rest.of_download(/*url*/ "/orders/4152/invoice.pdf", /*path*/ "C:\temp\invoice-4152.pdf")
if ll_status = 200 then
	Run("C:\temp\invoice-4152.pdf")
elseif ll_status < 0 then
	MessageBox("Download", lnv_rest.of_get_last_error())
end if

An API that answers with an error #

// Local variables
n_pbt_json lnv_j

// The status decides : 200 or 201 = created, 400 = the request was refused (is_last_request
// tells what it asked for, its secrets masked), 401 = the token expired, below 0 = the network failed
lnv_j.of_set_number(/*path*/ "order", /*value*/ 4152)
choose case lnv_rest.of_post(/*url*/ "/shipments", /*body*/ lnv_j.of_text())
	case 200, 201
	case 400
		MessageBox("API", lnv_rest.of_json_value(/*path*/ "message") + "~r~n~r~n" + lnv_rest.is_last_request)
	case 401
		MessageBox("API", "Token expired : " + lnv_rest.of_json_value(/*path*/ "message"))
	case is < 0
		MessageBox("Network", lnv_rest.of_get_last_error())
end choose

An intranet API with Windows authentication #

// Local variables
n_pbt_restclient lnv_rest
long ll_status

// An IIS API of the intranet : the Windows session answers the server's challenge,
// nothing is typed. Off by default : switch it on for this client only.
lnv_rest = create n_pbt_restclient
lnv_rest.is_base_url = "https://erp.intranet.local/api"
lnv_rest.ib_windows_auth = true

// A header refused (a line break in the value) returns -5 and is not set
if lnv_rest.of_set_header(/*name*/ "X-Client", /*value*/ "PowerBuilder") < 0 then return

// Read an order : 401 when the server refused the Windows account
ll_status = lnv_rest.of_get(/*url*/ "/orders/4152")
if ll_status = 401 then
	MessageBox("ERP", "Access denied")
elseif ll_status < 0 then
	MessageBox("ERP", lnv_rest.of_get_last_error())
end if

// The client is no longer needed
destroy lnv_rest

A search typed by the user #

// Local variables
string ls_names[], ls_values[]
long ll_status

// What the user typed may hold # & + : of_url encodes every name and every value
ls_names[1] = "q"
ls_values[1] = sle_search.text
ls_names[2] = "page"
ls_values[2] = "2"
ll_status = lnv_rest.of_get(/*url*/ lnv_rest.of_url(/*path*/ "/items", /*names*/ ls_names, /*values*/ ls_values))

An API protected by OAuth2 #

// Local variables
n_pbt_restclient lnv_api

// One client for an API protected by OAuth2 (Azure AD, Keycloak, Salesforce...) : the token
// is asked once, then renewed by the client itself a minute before it expires
lnv_api = create n_pbt_restclient
lnv_api.is_base_url = "https://api.example.com"
if lnv_api.of_oauth_client_credentials(/*token_url*/ "https://login.example.com/oauth2/token", /*client*/ "my-app", /*secret*/ ProfileString("app.ini", "api", "secret", ""), /*scope*/ "orders.read") < 0 then
	MessageBox("API", lnv_api.of_get_last_error())
	return
end if

// Every request now carries the token
lnv_api.of_get(/*url*/ "/orders/4152")
destroy lnv_api

A list into a DataStore #

// Local variables
datastore lds_orders
long ll_rows

// The open orders of the API straight into a DataStore, as Retrieve does from a database :
// one row per object, each member in the column of its name, the rows not modified
lds_orders = create datastore
lds_orders.DataObject = "d_orders"
ll_rows = lnv_rest.of_retrieve(/*target*/ lds_orders, /*url*/ "/orders?status=open", /*path*/ "items")
if ll_rows < 0 then MessageBox("API", lnv_rest.of_get_last_error())

Asynchronous (without freezing the UI) #

During a synchronous call the UI is frozen until the answer: the window no longer repaints and no longer answers clicks. That does not matter for a short call; for any call that may take time — a slow server, a large body, an uncertain network —, use the asynchronous API: the request runs on a worker thread and its answer comes back later as an event. Synchronous and async calls share the same session: the cookie set by a synchronous login serves the async calls that follow, and the other way round; a synchronous call never waits behind the async requests of the same object.

Setup — nothing to wire: the component delivers the answer on its own in ue_response. of_open / of_close open and close the client (optional: the first request opens it), of_clear_cookies forgets the cookies kept between requests. Internally the component's pump calls of_process_events to drain the answers — you never call it.

Send — of_request_async(method, url, body), or the shortcuts of_get_async, of_post_async, of_put_async, of_patch_async, of_delete_async, return a request id at once — or -5 at once, with no event afterwards, for an invalid URL or header (of_get_last_error() says why, for instance "invalid URL: …"). Six requests at most run at the same time; the next ones wait their turn in a queue. of_download_async(url, path) downloads to a file and of_upload(url, field, path) uploads a file as multipart/form-data — both with progress. of_cancel(id) stops a request. Retries (il_max_retries, il_retry_backoff_ms) replay on 429/503/network failure.

Receive — ue_response(al_id, al_status) (read the body with of_response_text(al_id) / of_status(al_id) INSIDE the handler), ue_failed(al_id, al_code, as_error), ue_progress(al_id, al_done, al_total). An id is valid only for the object that sent the request. A runtime error in one of these events is not swallowed: it reaches the application's SystemError event like any script error, and the next answers are delivered all the same.

MethodRole
of_open ( ) → longOpens the client: its session (cookies shared by the synchronous and async calls) and its native event channel (no WebView, no CORS). Optional: the first request opens it. Returns the client id (> 0), or -2 when it could not be created (of_get_last_error() says why)
of_close ( ) → longCloses the client: its cookies (the session) go with it, and its requests still in flight are cancelled without any event — no ue_response, no ue_failed. The next request reopens it. Done for you by of_reset and on destroy. The object's last synchronous response is forgotten with it Returns 0 once done.
of_process_events ( )Drains the async answers and raises ue_response / ue_failed / ue_progress. Public ONLY because the component's own pump calls it on the PowerBuilder loop, every few milliseconds while a request is in flight — you never call it, and you wire neither a receiver nor a timer. Calling it yourself is harmless: it only empties what is already waiting
of_clear_cookies ( )Forgets the cookies kept between requests (a logout). Whether cookies are kept from then on is still ib_keep_cookies
of_get_async (string as_url) → longAsync GET. Returns a request id at once; the answer arrives in ue_response(id, status). Returns -5 at once — and no event follows — for an invalid URL or header (of_get_last_error() says why), -2 if the client cannot open
of_post_async (string as_url, string as_body) → longAsync POST with a body (JSON by default, built with n_pbt_json). Returns a request id at once; the answer comes in ue_response. Returns -5 / -2 like of_get_async
of_post_form_async (string as_url, string as_names[], string as_values[]) → longThe same form as of_post_form, sent without waiting. Returns a request id at once; the answer comes in ue_response. Returns -5 / -2 like of_get_async
of_put_async (string as_url, string as_body) → longAsync PUT with a body. Returns a request id at once; the answer comes in ue_response. Returns -5 / -2 like of_get_async
of_patch_async (string as_url, string as_body) → longAsync PATCH with a body. Returns a request id at once; the answer comes in ue_response. Returns -5 / -2 like of_get_async
of_delete_async (string as_url) → longAsync DELETE. Returns a request id at once; the answer comes in ue_response. Returns -5 / -2 like of_get_async
of_request_async (string as_method, string as_url, string as_body {, string as_headers}) → longAsync request with the HTTP method of your choice (of_get_async and its siblings call it). Returns a request id at once; the answer comes in ue_response, a failure in ue_failed. Six requests at most at the same time, the next ones wait their turn. Returns -2 when the client cannot open, -4 when the request cannot be queued, -5 at once — with no event — for an invalid URL or header. as_headers: headers for this request only, as for of_request
of_download_async (string as_url, string as_path) → longAsync GET straight to a FILE, with ue_progress along the way. Returns a request id, -5 on an empty path, -6 refused at once in demo mode, -2 when the client cannot open. A relative path is resolved at the call, as for of_download
of_upload (string as_url, string as_field, string as_path) → longUploads a FILE as multipart/form-data (form field as_field), async, with ue_progress — always a POST, with that single field. Returns a request id, -5 on an empty path, -6 refused at once in demo mode, -2 when the client cannot open. A relative file is looked for in the current folder, then in the application's folder when the library was loaded, then next to the exe. The Content-Type set by of_set_header is ignored: multipart/form-data is set by the library
of_cancel (long al_id) → longAsks a request to stop; it ends with ue_failed(id, -4, "cancelled"), at once if it was still waiting its turn. Returns 0, -5 if the id is unknown or belongs to another object — nothing is cancelled —, -4 if it is too late: the answer has arrived and comes in ue_response
of_status (long al_id) → longThe HTTP status of an async answer, by request id — read it INSIDE ue_response. Returns the status (200, 404…); 0 while the request runs, for a failed request (in ue_failed), for an id unknown, already forgotten or of another object
of_response_text (long al_id) → stringThe body of an async answer, by request id — read it INSIDE ue_response: the request is forgotten once the event returns; empty while it is running; empty too for the id of another object
of_response_headers (long al_id) → stringEvery header of an async answer, one per line
of_response_header (long al_id, string as_name) → stringOne header of an async answer, by name (case does not matter); repeated values joined as of_response_header(as_name) joins them
of_json_value (long al_id, string as_path) → stringOne value of an async answer's JSON body, by path — the same notation as of_json_value(as_path). Read it in ue_response; read before the answer (in ue_progress) it returns empty, and the answer is still read when it comes
EventRole
ue_response (long al_id, long al_status)An async request finished: al_id is the id returned when sending, al_status the HTTP status. Read the body with of_response_text(al_id) INSIDE the handler: the request is forgotten once the event returns. It is written on the client object placed in the window, or in a descendant (see "Where to write the events"). Closing from it the window that holds the client is allowed: the answers still waiting are not delivered
ue_failed (long al_id, long al_code, string as_error)An async request failed. al_code says the kind: -4 the request failed (no network, timeout, TLS, body too large, or cancelled by of_cancel — as_error is then cancelled), -5 invalid file or client certificate not found, -6 refused in demo mode (a Range request); as_error says why. of_status(al_id) returns 0 there
ue_progress (long al_id, long al_done, long al_total)Progress of an async request: the body of the answer as it arrives (any request, a large JSON included), the file of a download (of_download_async) or of an upload (of_upload). al_done bytes out of al_total (0 when the server did not announce the size); the last one, just before ue_response, says al_done = al_total. Past 2 GB the byte counts no longer fit a long

Where to write the events #

An object made by create n_pbt_restclient serves the synchronous calls very well — it is what the quick start does —, but its events have no script: PowerBuilder cannot write ue_response on an object created in a script. For the async calls, two ways:

Closing the window that holds the client from one of its events is allowed: the answers still waiting are not delivered.

// Local variables
long ll_id

// Async : the UI never freezes, and there is nothing to wire. inv_rest is the restclient
// placed in the window ; the request id comes back at once
ll_id = inv_rest.of_get_async(/*url*/ "https://api.example.com/orders/4152")
// Local variables
string ls_body

// Event ue_response of inv_rest : the answer arrives on its own, and it is read here
if al_status >= 200 and al_status < 300 then ls_body = this.of_response_text(/*id*/ al_id)

Good practice #

← Component reference · Guide contents