@essenius/node-red-openhab4
Version:
OpenHAB 4 integration nodes for Node-RED
184 lines (162 loc) • 7.88 kB
JavaScript
// Copyright 2025 Rik Essenius
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software distributed under the License is
// distributed on an "AS IS" BASIS WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and limitations under the License.
;
let fetchImpl;
try {
fetchImpl = global.fetch || require("node-fetch");
} catch (err) {
throw new Error("No fetch available. Install 'node-fetch' or upgrade Node.js. Error: " + err.message);
}
/** Enhance the fetch function to handle errors and return a standardized response. Takes care of setting properties
* ok, retry, authFailed, authRequired, and message on the error object. */
function responseStatus(response, data, hasCredentials) {
const status = response.status || 500;
if (Math.floor(status / 100) === 2) {
return { ok: true, status };
}
const message = getErrorMessage(data, status, response.statusText);
const errorObject = baseErrorObject(status, hasCredentials, message);
let error = new Error();
Object.assign(error, errorObject);
return error;
/** create an error message based on the response, the response body, and the status */
function getErrorMessage(data, status, statusText) {
if (data?.error?.message) {
return data.error.message;
}
if (typeof data === "string") {
return data;
}
if (statusText) {
return statusText;
}
return `HTTP Error ${status}`;
}
function baseErrorObject(status, hasCredentials, message) {
switch (status) {
// Service Unavailable can be sent when OpenHAB is restarting
case 503: return { retry: true, status, message };
// Not Found can also be sent when OpenHAB is restarting, or if the URL is incorrect
// if the response message contains "does not exist", this is most likely a request to a non-existing resource where a rety won't help
case 404: return message.includes("does not exist") ? { status, message } : { retry: true, status, message };
case 401: return hasCredentials
? { authFailed: true, status, message: "Wrong credentials provided." }
: { authRequired: true, status, message: "No credentials provided." };
// all others are treated as errors
default:
return { status, message };
}
}
}
/** Retrieve the response data, handling empty responses and content types JSON and text. Returns null for no content. */
async function retrieveResponseData(response) {
let data = (await response.text()).trim();
if (data === "") {
return null; // No content, return null
}
const contentType = response.headers.get("content-type");
if (contentType?.includes("application/json")) {
return (data.trim() === "") ? null : JSON.parse(data);
}
if (contentType?.includes("text/plain")) {
return data;
}
throw new Error(`Unsupported content type: ${contentType}`);
}
/** Perform an HTTP request using the fetch API. Returns a standardized response object with data or an error.
* Is flagged as complex because of the defaulting, but isn't really that complex. */
async function httpRequest(url, config, options = {}) {
options.headers = options.headers || {};
const hasCredentials = !!config.username;
if (hasCredentials) {
const auth = Buffer.from(`${config.username}:${setWithDefault(config.password, "")}`).toString("base64");
options.headers["Authorization"] = `Basic ${auth}`;
}
let response;
let data;
try {
response = await fetchImpl(url, options);
data = await retrieveResponseData(response);
} catch (error) { // this is thrown by fetchImpl in case of network errors (response will be undefined) or by retrieveResponseData (error.cause will be undefined).
if (error.cause) {
error.status = error.cause.errno;
error.message = error.cause.code;
} else {
// default to internal server error, but leave the error message as is
error.status = 500;
error.message = setWithDefault(error.message, "Fetch failed");
}
throw error;
}
const statusObject = responseStatus(response, data, hasCredentials);
if (statusObject.ok) return { ...statusObject, data };
// if not ok, then status is an error object
throw statusObject;
}
/** Set default values for the config data. This should be called very early on, when the config is injected first
* (i.e. when the controller node is created). */
function setDefaults(config) {
// Set default values for config properties
config.protocol = setWithDefault(config.protocol, "http");
config.host = setWithDefault(config.host, "localhost");
config.port = setWithDefault(config.port, config.protocol === "https" ? 8443 : 8080);
config.path = _trimSlashes(setWithDefault(config.path, ""));
config.username = setWithDefault(config.username, "");
config.password = setWithDefault(config.password, "", { noTrim: true });
config.allowSelfSigned = !!config.allowSelfSigned;
return config;
}
/** assemble the connection string from the config data. Expects that the config has been set up with setDefaults first.
* Can insert credentials for SSE (if options.includeCredentials is set) since that doesn't support the Authentication header */
function getConnectionString(config, options = {}) {
let url = config.protocol + "://";
if (options.includeCredentials && config.username?.length > 0) {
// Embed credentials in the URL for EventSource (SSE) as that has no other way to pass them
const user = encodeURIComponent(config.username);
const pass = encodeURIComponent(config.password);
url += `${user}:${pass}@`;
}
url += `${config.host}:${config.port}`;
if (config.path?.length > 0) url += "/" + config.path;
return url;
}
/** Check if the error is a phantom error, i.e. an error that does not contain any useful information. and can be ignored. */
function isPhantomError(err) {
if (err == null || typeof err !== 'object') return false;
if (!err.type || typeof err.type !== 'object') return false;
return Object.keys(err.type).length === 0;
}
/** Set a property with a default value if it is not specified.
* If the property is a string, it will be trimmed unless noTrim is set in options. */
function setWithDefault(property, defaultValue, options = {}) {
if (property === undefined || property === null) return defaultValue;
if (typeof property === "string") {
if (!options.noTrim) property = property.trim();
return property.length > 0 ? property : defaultValue;
}
if (typeof property === "number") {
return isNaN(property) ? defaultValue : property;
}
// For other types, return as is (safety net, should not be needed for setDefaults)
return property;
}
/** Trim leading and trailing slashes from a string. This is useful for paths in URLs. */
function _trimSlashes(str) {
return str.replace(/^\/+|\/+$/g, '');
}
module.exports = {
fetch: fetchImpl,
httpRequest,
getConnectionString,
isPhantomError,
responseStatus,
setDefaults
};