Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e4220f3d74 | |||
| 93e5e34033 | |||
| 2815297c25 | |||
| a110a60f0d | |||
| 602e10bd31 | |||
| 710c53938d | |||
| 5d19dafbe4 | |||
| 4bef00e2d8 | |||
| 9857433246 | |||
| 174dd76a46 | |||
| 3fe1433a5d |
@@ -1,52 +1,88 @@
|
|||||||
# node-red-odbcwritenow
|
# node-red-odbcwritenow
|
||||||
|
--------
|
||||||
|
|
||||||
Node-RED node for accessing MYOB data via **ODBCWriteNow**. Useful when you want AccountRight-style ODBC-style reads/writes in Node-RED without wrangling the raw HTTP yourself.
|
Node-RED node for downloading MYOB data from ODBCWriteNow / MYOBSync.
|
||||||
|
|
||||||
## Features
|
This package provides one node type: `odbcwritenow-get`.
|
||||||
- Node-RED palette node that talks to ODBC WriteNow
|
|
||||||
- Read/write operations against MYOB AccountRight via the ODBC WriteNow API
|
## What It Does
|
||||||
- Simple config for credentials and endpoint base URL
|
|
||||||
|
The node builds and calls:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://myobsync.accede.com.au/download/{what}/json/{page}?apikey={apikey}[&filters=...][&datefrom=...][&dateto=...][&orderby=...]
|
||||||
|
```
|
||||||
|
|
||||||
|
It then:
|
||||||
|
|
||||||
|
- sends parsed JSON rows to output 1
|
||||||
|
- sends "no data" completion messages to output 2
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
- Node-RED ≥ 4.x installed and running
|
|
||||||
- An active **ODBC WriteNow** account + API key
|
- Node-RED `>= 4.0.0`
|
||||||
|
- Valid ODBCWriteNow / MYOBSync API key
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
|
Install in your Node-RED user directory:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# install into your Node-RED user dir
|
|
||||||
cd ~/.node-red
|
cd ~/.node-red
|
||||||
npm install DarkAxi0m/node-red-odbcwritenow
|
npm install @accede/node-red-contrib-odbcwritenow
|
||||||
```
|
```
|
||||||
|
|
||||||
Restart Node-RED.
|
Restart Node-RED after installation.
|
||||||
|
|
||||||
If you manage Node-RED as a service:
|
## Node Configuration
|
||||||
```bash
|
|
||||||
sudo systemctl restart nodered
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
Editor fields:
|
||||||
|
|
||||||
1. In the Node-RED editor, open the palette and drag **ODBC WriteNow** onto your flow.
|
- `Name` (optional)
|
||||||
2. Double-click the node and set:
|
- `What` (required): dataset/resource name, for example `sales_invoice_item`
|
||||||
- **Base URL**: your ODBC WriteNow endpoint (e.g. `https://myobsync.accede.com.au/`)
|
- `OrderBy` (optional): default sort expression
|
||||||
- **API Key**: your issued key
|
- `MaxRetries` (optional, default `3`): number of retries for timeout/token errors
|
||||||
- **Operation/Path**: API route you need (e.g. download/upload endpoints per docs)
|
- `RetryBackoffMs` (optional, default `500`): base delay in milliseconds; each retry uses exponential backoff (`base * 2^attempt`)
|
||||||
- **Params/Body**: any query/body fields required for your action
|
- `APIKey` (required unless provided in `msg.apikey`)
|
||||||
|
|
||||||
Refer to the ODBC WriteNow developer docs for the exact routes and parameters.
|
## Runtime Inputs (`msg`)
|
||||||
|
|
||||||
Example (generic pattern):
|
You can override behavior per message:
|
||||||
```text
|
|
||||||
GET /api/download?table=Customers&updatedSince=2024-01-01
|
- `msg.page` (default `0`)
|
||||||
POST /api/upload (JSON body with rows)
|
- `msg.apikey` (overrides configured API key)
|
||||||
```
|
- `msg.orderby` (overrides configured order by)
|
||||||
|
- `msg.filters`
|
||||||
|
- `msg.datefrom`
|
||||||
|
- `msg.dateto`
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
`odbcwritenow-get` has 2 outputs:
|
||||||
|
|
||||||
|
1. Data output
|
||||||
|
- `msg.payload`: parsed JSON array returned by the API
|
||||||
|
- `msg.rows`: number of rows in `payload`
|
||||||
|
- `msg.page`, `msg.what`, `msg.retry`
|
||||||
|
|
||||||
|
2. No-data output
|
||||||
|
- emitted when response contains `"no data found"`
|
||||||
|
- `msg.payload = []`
|
||||||
|
- `msg.nodata = true`
|
||||||
|
- `msg.complete = true`
|
||||||
|
|
||||||
|
## Status Behavior
|
||||||
|
|
||||||
|
- Blue ring: currently fetching
|
||||||
|
- Green dot: rows returned
|
||||||
|
- Green ring: no data found
|
||||||
|
- Red ring: timeout/token error retries or request/parsing error
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Base URL is currently fixed to `https://myobsync.accede.com.au`.
|
||||||
|
- Timeout and token error responses are retried using bounded exponential backoff.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
ISC © Accede Holdings PTY LTD. See `LICENSE`.
|
|
||||||
|
|
||||||
## Links
|
ISC. See [LICENSE](LICENSE).
|
||||||
- Repo: [DarkAxi0m/node-red-odbcwritenow](https://github.com/DarkAxi0m/node-red-odbcwritenow)
|
|
||||||
- [ODBC WriteNow – Overview & pricing](https://odbcwritenow.com/)
|
|
||||||
- [ODBC WriteNow – Developer docs](https://odbcwritenow.com/developers/)
|
|
||||||
|
|||||||
+11
-2
@@ -6,7 +6,9 @@
|
|||||||
name: {value:""},
|
name: {value:""},
|
||||||
apikey: {value:""},
|
apikey: {value:""},
|
||||||
what: {value:""},
|
what: {value:""},
|
||||||
orderby: {value:""}
|
orderby: {value:""},
|
||||||
|
maxRetries: {value:3},
|
||||||
|
retryBackoffMs: {value:500}
|
||||||
},
|
},
|
||||||
inputs: 1,
|
inputs: 1,
|
||||||
outputs: 2,
|
outputs: 2,
|
||||||
@@ -30,6 +32,14 @@
|
|||||||
<label for="node-input-orderby"><i class="fa fa-lock"></i> OrderBy</label>
|
<label for="node-input-orderby"><i class="fa fa-lock"></i> OrderBy</label>
|
||||||
<input type="text" id="node-input-orderby" placeholder="OrderBy">
|
<input type="text" id="node-input-orderby" placeholder="OrderBy">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-maxRetries"><i class="fa fa-repeat"></i> MaxRetries</label>
|
||||||
|
<input type="number" id="node-input-maxRetries" min="0" placeholder="3">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-retryBackoffMs"><i class="fa fa-clock-o"></i> RetryBackoffMs</label>
|
||||||
|
<input type="number" id="node-input-retryBackoffMs" min="0" placeholder="500">
|
||||||
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="node-input-apikey"><i class="fa fa-lock"></i> APIKey</label>
|
<label for="node-input-apikey"><i class="fa fa-lock"></i> APIKey</label>
|
||||||
<input type="text" id="node-input-apikey" placeholder="APIKey">
|
<input type="text" id="node-input-apikey" placeholder="APIKey">
|
||||||
@@ -39,4 +49,3 @@
|
|||||||
<script type="text/html" data-help-name="odbcwritenow-get">
|
<script type="text/html" data-help-name="odbcwritenow-get">
|
||||||
<p>odbcwritenow downloader</p>
|
<p>odbcwritenow downloader</p>
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
+100
-44
@@ -1,45 +1,75 @@
|
|||||||
async function DoImport(msg, url, node) {
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function DoImport(msg, url, node, maxRetries, baseBackoffMs) {
|
||||||
console.log('>', url)
|
console.log('>', url)
|
||||||
msg.nodata = false
|
msg.nodata = false
|
||||||
delete msg.complete
|
delete msg.complete
|
||||||
node.status({ fill: "blue", shape: "ring", text: `Fetching #${msg.page}: ${msg.what}` })
|
let datastr = ""
|
||||||
var datastr = ""
|
let attempt = 0
|
||||||
try {
|
|
||||||
const response = await fetch(url);
|
|
||||||
|
|
||||||
datastr = await response.text();
|
while (attempt <= maxRetries) {
|
||||||
if (datastr.toLowerCase().includes("no data found")) {
|
msg.retry = attempt
|
||||||
msg.nodata = true
|
node.status({ fill: "blue", shape: "ring", text: `Fetching #${msg.page}: ${msg.what} (try ${attempt + 1}/${maxRetries + 1})` })
|
||||||
msg.complete = true
|
|
||||||
msg.payload = []
|
|
||||||
node.status({ fill: "green", shape: "ring", text: `#${msg.page}: No data found` })
|
|
||||||
return node.send([null, msg]);
|
|
||||||
}
|
|
||||||
|
|
||||||
//Errors and retrys etc
|
try {
|
||||||
if (datastr.toLowerCase().includes("timeout")) {
|
const response = await fetch(url);
|
||||||
node.status({ fill: "red", shape: "ring", text: `#${msg.page}: MYOB Gateway Timeout` })
|
if (!response.ok) {
|
||||||
console.error("******* MYOB Gateway Timeout", msg.retry, msg.page, url)
|
throw new Error(`HTTP ${response.status} ${response.statusText}`)
|
||||||
msg.retry = msg.retry + 1
|
}
|
||||||
return await DoImport(msg, url, node)
|
datastr = await response.text();
|
||||||
|
|
||||||
|
if (datastr.toLowerCase().includes("no data found")) {
|
||||||
|
console.log("no data found");
|
||||||
|
msg.nodata = true
|
||||||
|
msg.complete = true
|
||||||
|
msg.payload = []
|
||||||
|
node.status({ fill: "green", shape: "ring", text: `#${msg.page}: No data found` })
|
||||||
|
return node.send([null, msg]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry only for known transient responses.
|
||||||
|
const lowerData = datastr.toLowerCase()
|
||||||
|
const hasTimeout = lowerData.includes("timeout")
|
||||||
|
const hasTokenError = lowerData.includes("token error")
|
||||||
|
const hasInternalError = lowerData.includes("internalerror") || lowerData.includes("internal error")
|
||||||
|
|
||||||
|
if (hasTimeout || hasTokenError || hasInternalError) {
|
||||||
|
const isLastTry = attempt >= maxRetries
|
||||||
|
let kind = "MYOB Retryable Error"
|
||||||
|
if (hasTimeout) kind = "MYOB Gateway Timeout"
|
||||||
|
if (hasTokenError) kind = "MYOB Token Error"
|
||||||
|
if (hasInternalError) kind = "MYOB InternalError"
|
||||||
|
|
||||||
|
if (isLastTry) {
|
||||||
|
node.status({ fill: "red", shape: "ring", text: `#${msg.page}: ${kind} (max retries reached)` })
|
||||||
|
return node.error(`${kind} after ${maxRetries + 1} attempts`, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
const delayMs = Math.max(0, baseBackoffMs) * Math.pow(2, attempt)
|
||||||
|
node.status({ fill: "red", shape: "ring", text: `#${msg.page}: ${kind}, retrying in ${delayMs}ms` })
|
||||||
|
console.error("*******", kind, attempt, msg.page, `retry in ${delayMs}ms`)
|
||||||
|
attempt += 1
|
||||||
|
await sleep(delayMs)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
//---------------------------------
|
||||||
|
// Everything looks good.
|
||||||
|
const data = JSON.parse(datastr);
|
||||||
|
msg.rows = data.length
|
||||||
|
console.log("Page:", msg.page, "Rows:", data.length)
|
||||||
|
|
||||||
|
node.status({ fill: "green", shape: "dot", text: `#${msg.page}: ${msg.rows} Rows` })
|
||||||
|
msg.payload = data;
|
||||||
|
return node.send([msg, null]);
|
||||||
|
} catch (error) {
|
||||||
|
node.status({ fill: "red", shape: "ring", text: error.message })
|
||||||
|
console.error(error.message)
|
||||||
|
console.log(datastr)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if (datastr.toLowerCase().includes("token error")) {
|
|
||||||
node.status({ fill: "red", shape: "ring", text: `#${msg.page}: MYOB Token Error` })
|
|
||||||
console.error("******* MYOB Token Error", msg.retry, msg.page, url)
|
|
||||||
msg.retry = msg.retry + 1
|
|
||||||
return await DoImport(msg, url, node)
|
|
||||||
}
|
|
||||||
//---------------------------------
|
|
||||||
//Everything looks good
|
|
||||||
const data = JSON.parse(datastr);
|
|
||||||
msg.rows = data.length
|
|
||||||
node.status({ fill: "green", shape: "dot", text: `#${msg.page}: ${msg.rows} Rows` })
|
|
||||||
msg.payload = data;
|
|
||||||
node.send([msg, null]);
|
|
||||||
} catch (error) {
|
|
||||||
node.status({ fill: "red", shape: "ring", text: error.message })
|
|
||||||
console.error(error.message)
|
|
||||||
console.log(datastr)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -51,16 +81,43 @@ module.exports = function(RED) {
|
|||||||
var node = this;
|
var node = this;
|
||||||
node.status({ text: `` })
|
node.status({ text: `` })
|
||||||
node.on('input', async function(msg) {
|
node.on('input', async function(msg) {
|
||||||
const page = parseInt(encodeURIComponent(msg.page || 0));
|
const pageRaw = msg.page === undefined || msg.page === null || msg.page === "" ? 0 : msg.page
|
||||||
const apikey = encodeURIComponent(msg.apikey || config.apikey);
|
const page = parseInt(pageRaw, 10)
|
||||||
const what = encodeURIComponent(config.what)
|
const apikeyRaw = msg.apikey || config.apikey
|
||||||
const orderby = encodeURIComponent(msg.orderby || config.orderby);
|
const whatRaw = config.what
|
||||||
|
const orderby = config.orderby;
|
||||||
|
|
||||||
|
if (!whatRaw || String(whatRaw).trim().length === 0) {
|
||||||
|
node.status({ fill: "red", shape: "ring", text: "Invalid config: 'what' is required" })
|
||||||
|
node.error("Invalid config: 'what' is required", msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apikeyRaw || String(apikeyRaw).trim().length === 0) {
|
||||||
|
node.status({ fill: "red", shape: "ring", text: "Missing API key" })
|
||||||
|
node.error("Missing API key: set config.apikey or msg.apikey", msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number.isNaN(page) || page < 0) {
|
||||||
|
node.status({ fill: "red", shape: "ring", text: "Invalid page: must be >= 0" })
|
||||||
|
node.error(`Invalid page '${pageRaw}': must be a non-negative integer`, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const apikey = encodeURIComponent(apikeyRaw);
|
||||||
|
const what = encodeURIComponent(whatRaw)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
msg.page = page
|
msg.page = page
|
||||||
msg.what = what
|
msg.what = whatRaw
|
||||||
msg.retry = 0
|
const maxRetries = Number.isInteger(parseInt(config.maxRetries, 10))
|
||||||
|
? Math.max(0, parseInt(config.maxRetries, 10))
|
||||||
|
: 3
|
||||||
|
const backoffMs = Number.isInteger(parseInt(config.retryBackoffMs, 10))
|
||||||
|
? Math.max(0, parseInt(config.retryBackoffMs, 10))
|
||||||
|
: 500
|
||||||
|
|
||||||
var orderbystr = "";
|
var orderbystr = "";
|
||||||
var filtersstr = "";
|
var filtersstr = "";
|
||||||
@@ -84,11 +141,10 @@ module.exports = function(RED) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const url = `https://myobsync.accede.com.au/download/${what}/json/${page}?apikey=${apikey}${filtersstr}${orderbystr}`;
|
const url = `https://myobsync.accede.com.au/download/${what}/json/${page}?apikey=${apikey}${filtersstr}${orderbystr}`;
|
||||||
DoImport(msg, url, node)
|
DoImport(msg, url, node, maxRetries, backoffMs)
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
RED.nodes.registerType("odbcwritenow-get", ODBCWriteNowGet);
|
RED.nodes.registerType("odbcwritenow-get", ODBCWriteNowGet);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "node-red-contrib-odbcwritenow",
|
"name": "node-red-contrib-odbcwritenow",
|
||||||
"version": "1.0.3",
|
"version": "1.0.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "node-red-contrib-odbcwritenow",
|
"name": "node-red-contrib-odbcwritenow",
|
||||||
"version": "1.0.3",
|
"version": "1.0.5",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"node-fetch": "^3.3.2"
|
"node-fetch": "^3.3.2"
|
||||||
|
|||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "@accede/node-red-contrib-odbcwritenow",
|
"name": "@accede/node-red-contrib-odbcwritenow",
|
||||||
"version": "1.0.3",
|
"version": "1.0.5",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "odbcwritenow.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user