node-red-contrib-ipgeolocation 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +423 -0
- package/examples/01 - Single IP Lookup.json +102 -0
- package/examples/02 - Bulk IP Lookup.json +98 -0
- package/examples/03 - IP Security Lookup.json +98 -0
- package/examples/04 - Bulk IP Security.json +96 -0
- package/examples/05 - Abuse Contact Lookup.json +98 -0
- package/examples/06 - ASN Lookup.json +102 -0
- package/examples/07 - Timezone Lookup.json +101 -0
- package/examples/08 - Astronomy (Sun & Moon).json +109 -0
- package/examples/09 - User-Agent Parser.json +96 -0
- package/index.js +22 -0
- package/nodes/ipgeo-abuse.html +203 -0
- package/nodes/ipgeo-abuse.js +148 -0
- package/nodes/ipgeo-asn.html +297 -0
- package/nodes/ipgeo-asn.js +197 -0
- package/nodes/ipgeo-astronomy.html +242 -0
- package/nodes/ipgeo-astronomy.js +195 -0
- package/nodes/ipgeo-bulk-security.html +166 -0
- package/nodes/ipgeo-bulk-security.js +177 -0
- package/nodes/ipgeo-bulk.html +158 -0
- package/nodes/ipgeo-bulk.js +146 -0
- package/nodes/ipgeo-config.html +72 -0
- package/nodes/ipgeo-config.js +30 -0
- package/nodes/ipgeo-lookup.html +300 -0
- package/nodes/ipgeo-lookup.js +165 -0
- package/nodes/ipgeo-security.html +214 -0
- package/nodes/ipgeo-security.js +150 -0
- package/nodes/ipgeo-timezone.html +201 -0
- package/nodes/ipgeo-timezone.js +154 -0
- package/nodes/ipgeo-useragent.html +133 -0
- package/nodes/ipgeo-useragent.js +110 -0
- package/nodes/lib/api-client.js +210 -0
- package/nodes/lib/node-helpers.js +214 -0
- package/package.json +72 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ipgeo-timezone.js
|
|
3
|
+
*
|
|
4
|
+
* Node-RED node: "ipgeo timezone"
|
|
5
|
+
*
|
|
6
|
+
* Timezone lookup via /v3/timezone supporting all input modes:
|
|
7
|
+
* tz — IANA timezone name (e.g. "America/New_York")
|
|
8
|
+
* ip — IPv4 or IPv6 address
|
|
9
|
+
* location— Free-text address (e.g. "New York, USA")
|
|
10
|
+
* coords — Latitude + Longitude
|
|
11
|
+
* iata — IATA airport code (e.g. "JFK")
|
|
12
|
+
* icao — ICAO airport code (e.g. "KJFK")
|
|
13
|
+
* unlocode— UN/LOCODE (e.g. "USNYC")
|
|
14
|
+
*
|
|
15
|
+
* Each input mode is selectable in the editor. The dynamic input
|
|
16
|
+
* value can be sourced from the node config (static) or msg properties.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const { request, IPGeoError } = require('./lib/api-client');
|
|
22
|
+
const {
|
|
23
|
+
setStatus,
|
|
24
|
+
resolveValue,
|
|
25
|
+
setOutputProperty,
|
|
26
|
+
handleNodeError,
|
|
27
|
+
} = require('./lib/node-helpers');
|
|
28
|
+
|
|
29
|
+
// Mapping from editor lookup-mode to API query parameter name
|
|
30
|
+
const MODE_PARAM = {
|
|
31
|
+
tz: 'tz',
|
|
32
|
+
ip: 'ip',
|
|
33
|
+
location: 'location',
|
|
34
|
+
coords: null, // lat + lng are separate params
|
|
35
|
+
iata: 'iata',
|
|
36
|
+
icao: 'icao',
|
|
37
|
+
unlocode: 'location', // UN/LO code is passed as 'location'
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
module.exports = function (RED) {
|
|
41
|
+
|
|
42
|
+
function IPGeoTimezoneNode(config) {
|
|
43
|
+
RED.nodes.createNode(this, config);
|
|
44
|
+
|
|
45
|
+
const configNode = RED.nodes.getNode(config.server);
|
|
46
|
+
|
|
47
|
+
this.name = config.name;
|
|
48
|
+
this.mode = config.mode || 'tz'; // lookup mode
|
|
49
|
+
this.inputType = config.inputType || 'str'; // msg | str | flow | global
|
|
50
|
+
this.inputValue = config.inputValue || '';
|
|
51
|
+
this.latType = config.latType || 'str';
|
|
52
|
+
this.latValue = config.latValue || '';
|
|
53
|
+
this.lngType = config.lngType || 'str';
|
|
54
|
+
this.lngValue = config.lngValue || '';
|
|
55
|
+
this.outputProp = config.outputProp || 'payload';
|
|
56
|
+
this.outputType = config.outputType || 'msg';
|
|
57
|
+
this.twoOutputs = config.twoOutputs !== false;
|
|
58
|
+
|
|
59
|
+
const node = this;
|
|
60
|
+
|
|
61
|
+
if (!configNode) {
|
|
62
|
+
setStatus(node, 'NO_KEY');
|
|
63
|
+
} else {
|
|
64
|
+
setStatus(node, 'IDLE');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
node.on('input', async function (msg, send, done) {
|
|
68
|
+
send = send || function () { node.send.apply(node, arguments); };
|
|
69
|
+
|
|
70
|
+
const apiKey = configNode && configNode.credentials
|
|
71
|
+
? configNode.credentials.apiKey : null;
|
|
72
|
+
|
|
73
|
+
if (!apiKey) {
|
|
74
|
+
setStatus(node, 'NO_KEY');
|
|
75
|
+
const err = new IPGeoError('No API key configured', 'NO_API_KEY');
|
|
76
|
+
handleNodeError(node, err, msg, done, send, node.twoOutputs);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Build query params ─────────────────────────────────────────────
|
|
81
|
+
const params = { apiKey };
|
|
82
|
+
const mode = node.mode;
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
if (mode === 'coords') {
|
|
86
|
+
// Latitude & Longitude — two separate resolved values
|
|
87
|
+
const lat = resolveValue(RED, node, msg,
|
|
88
|
+
{ value: node.latValue, type: node.latType });
|
|
89
|
+
const lng = resolveValue(RED, node, msg,
|
|
90
|
+
{ value: node.lngValue, type: node.lngType });
|
|
91
|
+
|
|
92
|
+
if (lat === undefined || lat === null || lat === '') {
|
|
93
|
+
throw new IPGeoError('Latitude is required for coords mode', 'INVALID_INPUT');
|
|
94
|
+
}
|
|
95
|
+
if (lng === undefined || lng === null || lng === '') {
|
|
96
|
+
throw new IPGeoError('Longitude is required for coords mode', 'INVALID_INPUT');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
params.lat = lat;
|
|
100
|
+
params.long = lng;
|
|
101
|
+
|
|
102
|
+
} else {
|
|
103
|
+
// Single-value modes
|
|
104
|
+
const paramKey = MODE_PARAM[mode];
|
|
105
|
+
if (!paramKey) {
|
|
106
|
+
throw new IPGeoError(`Unknown lookup mode: ${mode}`, 'INVALID_MODE');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const inputVal = resolveValue(RED, node, msg,
|
|
110
|
+
{ value: node.inputValue, type: node.inputType });
|
|
111
|
+
|
|
112
|
+
if (!inputVal && inputVal !== 0) {
|
|
113
|
+
throw new IPGeoError(`Input value is empty for mode "${mode}"`, 'INVALID_INPUT');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
params[paramKey] = String(inputVal).trim();
|
|
117
|
+
}
|
|
118
|
+
} catch (err) {
|
|
119
|
+
handleNodeError(node, err, msg, done, send, node.twoOutputs);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
setStatus(node, 'WORKING');
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const { data, credits } = await request({
|
|
127
|
+
method: 'GET',
|
|
128
|
+
path: '/timezone',
|
|
129
|
+
params,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const tzName = (data.time_zone && data.time_zone.name) || 'ok';
|
|
133
|
+
setStatus(node, 'OK', tzName);
|
|
134
|
+
|
|
135
|
+
setOutputProperty(RED, node, msg,
|
|
136
|
+
{ value: node.outputProp, type: node.outputType }, data);
|
|
137
|
+
|
|
138
|
+
if (credits !== null) msg.ipgeo_credits = credits;
|
|
139
|
+
delete msg.error;
|
|
140
|
+
|
|
141
|
+
send(node.twoOutputs ? [msg, null] : msg);
|
|
142
|
+
if (done) done();
|
|
143
|
+
|
|
144
|
+
} catch (err) {
|
|
145
|
+
handleNodeError(node, err, msg, done, send, node.twoOutputs);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
node.on('close', () => setStatus(node, 'IDLE'));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
RED.nodes.registerType('ipgeo-timezone', IPGeoTimezoneNode);
|
|
153
|
+
|
|
154
|
+
};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
<!-- ============================================================
|
|
2
|
+
ipgeo-useragent.html
|
|
3
|
+
User-Agent parser node
|
|
4
|
+
============================================================ -->
|
|
5
|
+
|
|
6
|
+
<script type="text/javascript">
|
|
7
|
+
(function () {
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
RED.nodes.registerType('ipgeo-useragent', {
|
|
11
|
+
category: 'IPGeolocation',
|
|
12
|
+
color: '#5da0d0',
|
|
13
|
+
icon: 'font-awesome/fa-desktop',
|
|
14
|
+
paletteLabel:'ipgeo user-agent',
|
|
15
|
+
|
|
16
|
+
defaults: {
|
|
17
|
+
name: { value: '' },
|
|
18
|
+
server: { value: '', type: 'ipgeo-config', required: true },
|
|
19
|
+
uaType: { value: 'msg' },
|
|
20
|
+
uaValue: { value: 'req.headers.user-agent' },
|
|
21
|
+
outputProp: { value: 'payload' },
|
|
22
|
+
outputType: { value: 'msg' },
|
|
23
|
+
twoOutputs: { value: true },
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
inputs: 1,
|
|
27
|
+
outputs: 2,
|
|
28
|
+
outputLabels: function (idx) { return idx === 0 ? 'success' : 'error'; },
|
|
29
|
+
|
|
30
|
+
label: function () { return this.name || 'ipgeo user-agent'; },
|
|
31
|
+
|
|
32
|
+
oneditprepare: function () {
|
|
33
|
+
const node = this;
|
|
34
|
+
|
|
35
|
+
$('#node-input-uaValue').typedInput({
|
|
36
|
+
default: 'msg',
|
|
37
|
+
typeField: '#node-input-uaType',
|
|
38
|
+
types: ['msg', 'flow', 'global', 'str'],
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
$('#node-input-outputProp').typedInput({
|
|
42
|
+
default: 'msg',
|
|
43
|
+
typeField: '#node-input-outputType',
|
|
44
|
+
types: ['msg', 'flow', 'global'],
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
$('#node-input-twoOutputs').prop('checked', node.twoOutputs !== false);
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
oneditsave: function () {
|
|
51
|
+
this.twoOutputs = $('#node-input-twoOutputs').prop('checked');
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}());
|
|
55
|
+
</script>
|
|
56
|
+
|
|
57
|
+
<!-- ── Edit template ── -->
|
|
58
|
+
<script type="text/html" data-template-name="ipgeo-useragent">
|
|
59
|
+
<div class="form-row">
|
|
60
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
61
|
+
<input type="text" id="node-input-name" placeholder="ipgeo user-agent" style="width:70%"/>
|
|
62
|
+
</div>
|
|
63
|
+
<div class="form-row">
|
|
64
|
+
<label for="node-input-server"><i class="fa fa-key"></i> API Config</label>
|
|
65
|
+
<input type="text" id="node-input-server" style="width:70%"/>
|
|
66
|
+
</div>
|
|
67
|
+
<hr/>
|
|
68
|
+
<div class="form-row">
|
|
69
|
+
<label for="node-input-uaValue"><i class="fa fa-desktop"></i> User-Agent</label>
|
|
70
|
+
<input type="text" id="node-input-uaValue" style="width:70%"/>
|
|
71
|
+
<input type="hidden" id="node-input-uaType"/>
|
|
72
|
+
<div style="margin-top:4px;font-size:0.82em;color:#888;">
|
|
73
|
+
Point to the message property that holds the UA string, or enter a
|
|
74
|
+
static string directly. Default: <code>msg.req.headers.user-agent</code>
|
|
75
|
+
(works with the built-in HTTP In node).
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
<hr/>
|
|
79
|
+
<div class="form-row">
|
|
80
|
+
<label for="node-input-outputProp"><i class="fa fa-sign-out"></i> Output to</label>
|
|
81
|
+
<input type="text" id="node-input-outputProp" style="width:70%"/>
|
|
82
|
+
<input type="hidden" id="node-input-outputType"/>
|
|
83
|
+
</div>
|
|
84
|
+
<div class="form-row">
|
|
85
|
+
<label><i class="fa fa-random"></i> Outputs</label>
|
|
86
|
+
<input type="checkbox" id="node-input-twoOutputs" style="width:auto;margin-right:6px"/>
|
|
87
|
+
<label for="node-input-twoOutputs" style="display:inline;font-weight:normal">
|
|
88
|
+
Use second output for errors
|
|
89
|
+
</label>
|
|
90
|
+
</div>
|
|
91
|
+
</script>
|
|
92
|
+
|
|
93
|
+
<!-- ── Help panel ── -->
|
|
94
|
+
<script type="text/html" data-help-name="ipgeo-useragent">
|
|
95
|
+
<p>
|
|
96
|
+
Parses a User-Agent string using the
|
|
97
|
+
<strong>IPGeolocation.io User-Agent API</strong> to identify the browser,
|
|
98
|
+
device type, OS, and rendering engine.
|
|
99
|
+
</p>
|
|
100
|
+
|
|
101
|
+
<h3>Input</h3>
|
|
102
|
+
<dl class="message-properties">
|
|
103
|
+
<dt>payload <span class="property-type">any</span></dt>
|
|
104
|
+
<dd>Passed through. The User-Agent string is read from a separate property.</dd>
|
|
105
|
+
</dl>
|
|
106
|
+
|
|
107
|
+
<h3>Output</h3>
|
|
108
|
+
<dl class="message-properties">
|
|
109
|
+
<dt>payload <span class="property-type">object</span></dt>
|
|
110
|
+
<dd>
|
|
111
|
+
<code>{ user_agent_string, name, type, version, version_major,
|
|
112
|
+
device: { name, type, brand, cpu },
|
|
113
|
+
engine: { name, type, version },
|
|
114
|
+
operating_system: { name, type, version, build } }</code>
|
|
115
|
+
</dd>
|
|
116
|
+
</dl>
|
|
117
|
+
|
|
118
|
+
<h3>Tip</h3>
|
|
119
|
+
<p>
|
|
120
|
+
Connect to Node-RED's built-in <strong>HTTP In</strong> node and set the
|
|
121
|
+
UA source to <code>msg.req.headers.user-agent</code> to automatically
|
|
122
|
+
parse visitor browser details.
|
|
123
|
+
</p>
|
|
124
|
+
|
|
125
|
+
<h3>References</h3>
|
|
126
|
+
<ul>
|
|
127
|
+
<li>
|
|
128
|
+
<a href="https://ipgeolocation.io/documentation/user-agent-api.html" target="_blank">
|
|
129
|
+
User-Agent API documentation
|
|
130
|
+
</a>
|
|
131
|
+
</li>
|
|
132
|
+
</ul>
|
|
133
|
+
</script>
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ipgeo-useragent.js
|
|
3
|
+
*
|
|
4
|
+
* Node-RED node: "ipgeo user-agent"
|
|
5
|
+
*
|
|
6
|
+
* Parses a User-Agent string via the IPGeolocation.io /v3/user-agent endpoint.
|
|
7
|
+
*
|
|
8
|
+
* Input
|
|
9
|
+
* ─────
|
|
10
|
+
* User-Agent string can be sourced from:
|
|
11
|
+
* • Static string in the node config
|
|
12
|
+
* • A msg property (e.g. msg.req.headers['user-agent'])
|
|
13
|
+
* • An incoming HTTP request forwarded through Node-RED's HTTP-in node
|
|
14
|
+
*
|
|
15
|
+
* Output
|
|
16
|
+
* ──────
|
|
17
|
+
* msg.payload (or configured output property) — parsed UA object with:
|
|
18
|
+
* { user_agent_string, name, type, version, device, engine, operating_system }
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
const { request, IPGeoError } = require('./lib/api-client');
|
|
24
|
+
const {
|
|
25
|
+
setStatus,
|
|
26
|
+
resolveValue,
|
|
27
|
+
setOutputProperty,
|
|
28
|
+
handleNodeError,
|
|
29
|
+
} = require('./lib/node-helpers');
|
|
30
|
+
|
|
31
|
+
module.exports = function (RED) {
|
|
32
|
+
|
|
33
|
+
function IPGeoUserAgentNode(config) {
|
|
34
|
+
RED.nodes.createNode(this, config);
|
|
35
|
+
|
|
36
|
+
const configNode = RED.nodes.getNode(config.server);
|
|
37
|
+
|
|
38
|
+
this.name = config.name;
|
|
39
|
+
this.uaType = config.uaType || 'msg'; // msg | str | flow | global
|
|
40
|
+
this.uaValue = config.uaValue || 'req.headers.user-agent';
|
|
41
|
+
this.outputProp = config.outputProp || 'payload';
|
|
42
|
+
this.outputType = config.outputType || 'msg';
|
|
43
|
+
this.twoOutputs = config.twoOutputs !== false;
|
|
44
|
+
|
|
45
|
+
const node = this;
|
|
46
|
+
|
|
47
|
+
if (!configNode) {
|
|
48
|
+
setStatus(node, 'NO_KEY');
|
|
49
|
+
} else {
|
|
50
|
+
setStatus(node, 'IDLE');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
node.on('input', async function (msg, send, done) {
|
|
54
|
+
send = send || function () { node.send.apply(node, arguments); };
|
|
55
|
+
|
|
56
|
+
const apiKey = configNode && configNode.credentials
|
|
57
|
+
? configNode.credentials.apiKey : null;
|
|
58
|
+
|
|
59
|
+
if (!apiKey) {
|
|
60
|
+
setStatus(node, 'NO_KEY');
|
|
61
|
+
const err = new IPGeoError('No API key configured', 'NO_API_KEY');
|
|
62
|
+
handleNodeError(node, err, msg, done, send, node.twoOutputs);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Resolve UA string ─────────────────────────────────────────────
|
|
67
|
+
const ua = resolveValue(RED, node, msg,
|
|
68
|
+
{ value: node.uaValue, type: node.uaType });
|
|
69
|
+
|
|
70
|
+
if (!ua || String(ua).trim() === '') {
|
|
71
|
+
const err = new IPGeoError(
|
|
72
|
+
'User-Agent string is empty or not found at the configured path',
|
|
73
|
+
'INVALID_INPUT'
|
|
74
|
+
);
|
|
75
|
+
handleNodeError(node, err, msg, done, send, node.twoOutputs);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
setStatus(node, 'WORKING');
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const { data, credits } = await request({
|
|
83
|
+
method: 'GET',
|
|
84
|
+
path: '/user-agent',
|
|
85
|
+
params: { apiKey },
|
|
86
|
+
headers: { 'User-Agent': String(ua).trim() },
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
setStatus(node, 'OK', data.name || 'parsed');
|
|
90
|
+
|
|
91
|
+
setOutputProperty(RED, node, msg,
|
|
92
|
+
{ value: node.outputProp, type: node.outputType }, data);
|
|
93
|
+
|
|
94
|
+
if (credits !== null) msg.ipgeo_credits = credits;
|
|
95
|
+
delete msg.error;
|
|
96
|
+
|
|
97
|
+
send(node.twoOutputs ? [msg, null] : msg);
|
|
98
|
+
if (done) done();
|
|
99
|
+
|
|
100
|
+
} catch (err) {
|
|
101
|
+
handleNodeError(node, err, msg, done, send, node.twoOutputs);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
node.on('close', () => setStatus(node, 'IDLE'));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
RED.nodes.registerType('ipgeo-useragent', IPGeoUserAgentNode);
|
|
109
|
+
|
|
110
|
+
};
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/api-client.js
|
|
3
|
+
*
|
|
4
|
+
* Shared Axios-based HTTP client for the IPGeolocation.io v3 API.
|
|
5
|
+
*
|
|
6
|
+
* Responsibilities
|
|
7
|
+
* ─────────────────
|
|
8
|
+
* • Single Axios instance with base URL and sensible timeouts
|
|
9
|
+
* • Exponential-backoff retry for transient errors (5xx, ECONNRESET, ETIMEDOUT)
|
|
10
|
+
* • Structured error objects that every node can map to Node-RED status/done(err)
|
|
11
|
+
* • Rate-limit awareness (HTTP 429 → honour Retry-After header when present)
|
|
12
|
+
* • Credit-charge extraction from response headers
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const axios = require('axios');
|
|
18
|
+
|
|
19
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
const BASE_URL = 'https://api.ipgeolocation.io/v3';
|
|
22
|
+
const DEFAULT_TIMEOUT = 10_000; // 10 s
|
|
23
|
+
const MAX_RETRIES = 3;
|
|
24
|
+
const RETRY_DELAY_MS = 500; // base delay; doubles each attempt
|
|
25
|
+
|
|
26
|
+
// HTTP status codes that are safe to retry (server-side transient faults)
|
|
27
|
+
const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
|
|
28
|
+
|
|
29
|
+
// ─── Error Classes ───────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
class IPGeoError extends Error {
|
|
32
|
+
/**
|
|
33
|
+
* @param {string} message Human-readable description
|
|
34
|
+
* @param {string} code Machine-readable code (e.g. 'AUTH_FAILED')
|
|
35
|
+
* @param {number} [status] HTTP status code, if applicable
|
|
36
|
+
* @param {object} [body] Raw API response body, if available
|
|
37
|
+
*/
|
|
38
|
+
constructor(message, code, status, body) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = 'IPGeoError';
|
|
41
|
+
this.code = code;
|
|
42
|
+
this.status = status || null;
|
|
43
|
+
this.body = body || null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Sleep for `ms` milliseconds (used in retry logic).
|
|
51
|
+
* @param {number} ms
|
|
52
|
+
* @returns {Promise<void>}
|
|
53
|
+
*/
|
|
54
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Map an Axios error to a typed IPGeoError with a user-friendly message.
|
|
58
|
+
*
|
|
59
|
+
* @param {import('axios').AxiosError} err
|
|
60
|
+
* @returns {IPGeoError}
|
|
61
|
+
*/
|
|
62
|
+
function mapAxiosError(err) {
|
|
63
|
+
if (err.response) {
|
|
64
|
+
const { status, data } = err.response;
|
|
65
|
+
const apiMsg = (data && (data.message || data.error)) || '';
|
|
66
|
+
|
|
67
|
+
switch (status) {
|
|
68
|
+
case 400: return new IPGeoError(
|
|
69
|
+
`Bad request: ${apiMsg || 'Invalid parameters'}`, 'BAD_REQUEST', 400, data);
|
|
70
|
+
case 401: return new IPGeoError(
|
|
71
|
+
`Authentication failed: ${apiMsg || 'Invalid or missing API key'}`, 'AUTH_FAILED', 401, data);
|
|
72
|
+
case 403: return new IPGeoError(
|
|
73
|
+
`Forbidden: ${apiMsg || 'Access denied (check plan limits or IP whitelist)'}`, 'FORBIDDEN', 403, data);
|
|
74
|
+
case 404: return new IPGeoError(
|
|
75
|
+
`Resource not found: ${apiMsg || 'Endpoint does not exist'}`, 'NOT_FOUND', 404, data);
|
|
76
|
+
case 422: return new IPGeoError(
|
|
77
|
+
`Unprocessable entity: ${apiMsg || 'Validation error'}`, 'VALIDATION', 422, data);
|
|
78
|
+
case 423: return new IPGeoError(
|
|
79
|
+
`API key locked: ${apiMsg || 'Quota exceeded or account suspended'}`, 'LOCKED', 423, data);
|
|
80
|
+
case 429: return new IPGeoError(
|
|
81
|
+
`Rate limit exceeded: ${apiMsg || 'Too many requests'}`, 'RATE_LIMITED', 429, data);
|
|
82
|
+
case 500: return new IPGeoError(
|
|
83
|
+
`API server error (500): ${apiMsg || 'Internal server error'}`, 'SERVER_ERROR', 500, data);
|
|
84
|
+
default: return new IPGeoError(
|
|
85
|
+
`HTTP ${status}: ${apiMsg || err.message}`, 'HTTP_ERROR', status, data);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (err.request) {
|
|
90
|
+
// Request was sent but no response received
|
|
91
|
+
const code = err.code || '';
|
|
92
|
+
if (code === 'ETIMEDOUT' || code === 'ECONNABORTED') {
|
|
93
|
+
return new IPGeoError(
|
|
94
|
+
`Request timed out after ${DEFAULT_TIMEOUT / 1000}s`, 'TIMEOUT');
|
|
95
|
+
}
|
|
96
|
+
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') {
|
|
97
|
+
return new IPGeoError(
|
|
98
|
+
'DNS resolution failed — check network connectivity', 'NETWORK_ERROR');
|
|
99
|
+
}
|
|
100
|
+
return new IPGeoError(`No response received: ${err.message}`, 'NO_RESPONSE');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Something went wrong building the request
|
|
104
|
+
return new IPGeoError(`Request setup error: ${err.message}`, 'REQUEST_ERROR');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─── Core Request Function ───────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Perform an HTTP GET or POST with automatic retries.
|
|
111
|
+
*
|
|
112
|
+
* @param {object} options
|
|
113
|
+
* @param {'GET'|'POST'} options.method
|
|
114
|
+
* @param {string} options.path Relative path, e.g. '/ipgeo'
|
|
115
|
+
* @param {object} [options.params] URL query parameters
|
|
116
|
+
* @param {object} [options.data] JSON body (POST only)
|
|
117
|
+
* @param {object} [options.headers] Additional HTTP headers
|
|
118
|
+
* @param {number} [options.timeout] Override default timeout (ms)
|
|
119
|
+
* @param {number} [options.maxRetries] Override default retry count
|
|
120
|
+
* @returns {Promise<{ data: any, credits: number|null }>}
|
|
121
|
+
*/
|
|
122
|
+
async function request(options) {
|
|
123
|
+
const {
|
|
124
|
+
method = 'GET',
|
|
125
|
+
path,
|
|
126
|
+
params = {},
|
|
127
|
+
data = undefined,
|
|
128
|
+
headers = {},
|
|
129
|
+
timeout = DEFAULT_TIMEOUT,
|
|
130
|
+
maxRetries = MAX_RETRIES,
|
|
131
|
+
} = options;
|
|
132
|
+
|
|
133
|
+
const url = `${BASE_URL}${path}`;
|
|
134
|
+
let attempt = 0;
|
|
135
|
+
|
|
136
|
+
while (attempt <= maxRetries) {
|
|
137
|
+
try {
|
|
138
|
+
const response = await axios({
|
|
139
|
+
method,
|
|
140
|
+
url,
|
|
141
|
+
params,
|
|
142
|
+
data,
|
|
143
|
+
headers: {
|
|
144
|
+
'Accept': 'application/json',
|
|
145
|
+
'Content-Type': 'application/json',
|
|
146
|
+
'User-Agent': 'node-red-contrib-ipgeolocation-io/1.0.0',
|
|
147
|
+
...headers,
|
|
148
|
+
},
|
|
149
|
+
timeout,
|
|
150
|
+
validateStatus: null, // We handle all status codes ourselves
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Successful response (2xx)
|
|
154
|
+
if (response.status >= 200 && response.status < 300) {
|
|
155
|
+
const credits = response.headers['x-credits-charged']
|
|
156
|
+
? Number(response.headers['x-credits-charged'])
|
|
157
|
+
: null;
|
|
158
|
+
return { data: response.data, credits };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Handle retryable server-side errors
|
|
162
|
+
if (RETRYABLE_STATUS.has(response.status) && attempt < maxRetries) {
|
|
163
|
+
let delay = RETRY_DELAY_MS * Math.pow(2, attempt);
|
|
164
|
+
|
|
165
|
+
// Honour Retry-After header for 429 responses
|
|
166
|
+
if (response.status === 429) {
|
|
167
|
+
const retryAfter = response.headers['retry-after'];
|
|
168
|
+
if (retryAfter) {
|
|
169
|
+
delay = Math.max(delay, Number(retryAfter) * 1000);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
attempt++;
|
|
174
|
+
await sleep(delay);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Non-retryable error — map and throw
|
|
179
|
+
const fakeAxiosError = { response };
|
|
180
|
+
throw mapAxiosError(fakeAxiosError);
|
|
181
|
+
|
|
182
|
+
} catch (err) {
|
|
183
|
+
// Axios network-level errors (ETIMEDOUT, ECONNRESET, etc.)
|
|
184
|
+
if (err instanceof IPGeoError) throw err; // already mapped, re-throw
|
|
185
|
+
|
|
186
|
+
const ipGeoErr = mapAxiosError(err);
|
|
187
|
+
|
|
188
|
+
// Retry on network-level transient faults
|
|
189
|
+
const isTransient =
|
|
190
|
+
ipGeoErr.code === 'TIMEOUT' ||
|
|
191
|
+
ipGeoErr.code === 'NO_RESPONSE' ||
|
|
192
|
+
ipGeoErr.code === 'NETWORK_ERROR';
|
|
193
|
+
|
|
194
|
+
if (isTransient && attempt < maxRetries) {
|
|
195
|
+
attempt++;
|
|
196
|
+
await sleep(RETRY_DELAY_MS * Math.pow(2, attempt));
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
throw ipGeoErr;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Should never reach here, but just in case
|
|
205
|
+
throw new IPGeoError('Max retries exceeded', 'MAX_RETRIES');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ─── Public API ──────────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
module.exports = { request, IPGeoError, BASE_URL };
|