From f7bbd67c4b0fadb0ef9f9f1d53355ec112804081 Mon Sep 17 00:00:00 2001 From: GermanBluefox Date: Sat, 3 Aug 2024 15:08:23 +0700 Subject: [PATCH] Preparations to remove "request" module --- lib/request.js | 186 +++++++++++++++++++++++++++++++++-------- package.json | 9 +- src-admin/package.json | 10 +-- src/package.json | 18 ++-- test/testRequest.js | 183 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 354 insertions(+), 52 deletions(-) create mode 100644 test/testRequest.js diff --git a/lib/request.js b/lib/request.js index 0f4c5337a..5256d4404 100644 --- a/lib/request.js +++ b/lib/request.js @@ -1,40 +1,157 @@ -// This module monkey-patches request for the sandbox -// so unhandled errors in the callback or forgetting to -// attach the error event handler does not bring down the adapter - -const _request = require('request'); +// This module monkey-patches "request" with axios. It is a part of the migration to axios. +const axios = require('axios'); +const URL = require('node:url').URL; let logger = { error: error => console.error(error), }; -function requestError(error) { - logger.error(`Request error: ${error}`); +function migrateAxiosResponse(response) { + const res = { + statusCode: response.status, + headers: response.headers, + body: response.data, + }; + + return res; } -/** - * Calls a request method which accepts a callback and handles errors in the request and the callback itself - * @param {(...args: any[]) => any} method - * @param {...any} args - */ -function requestSafe(method, ...args) { - const lastArg = args[args.length - 1]; - if (typeof lastArg === 'function') { - // If a callback was provided, handle errors in the callback - const otherArgs = args.slice(0, args.length - 1); - return method(...otherArgs, (...cbArgs) => { - try { - lastArg(...cbArgs); - } catch (e) { - logger.error(`Error in request callback: ${e}`); - } - }).on('error', requestError); +function migrateAxiosError(error) { + const err = { + code: error.code, + message: error.message, + stack: error.stack, + }; + if (error.config?.url) { + const url = new URL(error.config.url); + err.address = url.hostname; + err.port = parseInt(url.port, 10) || (url.protocol === 'https:' ? 443 : 80); + } + + if (error.response) { + err.statusCode = error.response.status; + err.headers = error.response.headers; + err.body = error.response.data; + } + + return err; +} + +function migrateRequestParams(url, requestParams) { + const axiosParams = {}; + + if (!requestParams.method) { + axiosParams.method = 'GET'; } else { - // otherwise, just pass the call through - return method(...args).on('error', requestError); + axiosParams.method = requestParams.method.toUpperCase(); + } + if (typeof requestParams.url === 'string') { + axiosParams.url = requestParams.url; + } + if (typeof url === 'string') { + axiosParams.url = url; + } + + if (requestParams.Headers || requestParams.headers) { + axiosParams.headers = requestParams.Headers || requestParams.headers; + } + if (requestParams.auth) { + axiosParams.headers = axiosParams.headers || {}; + if (requestParams.auth.user || requestParams.auth.username) { + axiosParams.headers.Authorization = `Basic ${Buffer.from(`${requestParams.auth.user || requestParams.auth.username}:${requestParams.auth.pass || requestParams.auth.password}`).toString('base64')}`; + } else if (requestParams.auth.bearer) { + axiosParams.headers.Authorization = `Bearer ${requestParams.auth.bearer}`; + } } + if (requestParams.method !== 'GET' && requestParams.method !== 'HEAD' && requestParams.method !== 'OPTIONS') { + if (requestParams.form) { + axiosParams.headers = axiosParams.headers || {}; + axiosParams.headers['Content-Type'] = 'application/x-www-form-urlencoded'; + axiosParams.data = requestParams.form; + } else if (requestParams.json) { + axiosParams.headers = axiosParams.headers || {}; + axiosParams.headers['Content-Type'] = 'application/json'; + axiosParams.data = requestParams.json; + } else if (requestParams.dataType === 'json') { + axiosParams.headers = axiosParams.headers || {}; + axiosParams.headers['Content-Type'] = 'application/json'; + } else if (requestParams.dataType === 'form') { + axiosParams.headers = axiosParams.headers || {}; + axiosParams.headers['Content-Type'] = 'application/x-www-form-urlencoded'; + } else if (requestParams.dataType === 'text') { + axiosParams.headers = axiosParams.headers || {}; + axiosParams.headers['Content-Type'] = 'text/plain'; + } else if (requestParams.dataType === 'xml') { + axiosParams.headers = axiosParams.headers || {}; + axiosParams.headers['Content-Type'] = 'application/xml'; + } else if (requestParams.formData) { + axiosParams.headers = axiosParams.headers || {}; + axiosParams.headers['Content-Type'] = 'multipart/form-data'; + const form = new FormData(); + for (const attr in requestParams.formData) { + if (Object.prototype.hasOwnProperty.call(requestParams.formData, attr)) { + form.append(attr, requestParams.formData[attr]); + } + } + axiosParams.data = form; + // axiosParams.form = form; + } else { + axiosParams.data = requestParams.data; + } + } + + if (!axiosParams.headers || axiosParams.headers['Content-Type'] !== 'multipart/form-data') { + axiosParams.transformResponse = x => x; + } + + return axiosParams; +} + +function migrateMethod(method) { + return function (url, options, callback) { + if (typeof options === 'function') { + callback = options; + options = {}; + } + if (typeof url === 'object') { + options = url; + } + + options = options || {}; + if (typeof url === 'string') { + options.url = url; + } + options.method = (method || 'GET').toUpperCase(); + + const axiosParams = migrateRequestParams(url, options); + + if (typeof callback === 'function') { + axios(axiosParams) + .then(response => { + if (options.json) { + try { + response.data = JSON.parse(response.data); + } catch (e) { + logger.error(`Cannot parse answer: ${response.data}`); + } + callback(null, migrateAxiosResponse(response), response.data); + } else { + callback(null, migrateAxiosResponse(response), response.data); + } + }) + .catch(error => { + logger.error(`Request error: ${error}`); + callback(migrateAxiosError(error)); + }); + } else { + axios(axiosParams) + .catch(error => logger.error(`Request error: ${error}`)); + } + }; } +const request = migrateMethod(); + // Wrap all methods that accept a callback const methodsWithCallback = [ 'get', @@ -46,11 +163,6 @@ const methodsWithCallback = [ 'delete', 'initParams', ]; -// and request itself -const request = (...args) => requestSafe(_request, ...args); -for (const methodName of methodsWithCallback) { - request[methodName] = (...args) => requestSafe(_request[methodName], ...args); -} // And copy all other properties and methods const otherPropsAndMethods = [ @@ -60,8 +172,14 @@ const otherPropsAndMethods = [ 'cookie', 'debug', ]; -for (const propName of otherPropsAndMethods) { - request[propName] = _request[propName]; +for (const method in otherPropsAndMethods) { + request[method] = function () { + logger.error(`Request error: method "${method}" is not implemented. Please migrate to axios`); + }; +} + +for (const method of methodsWithCallback) { + request[method] = migrateMethod(method); } request.setLogger = function (_logger) { @@ -69,4 +187,4 @@ request.setLogger = function (_logger) { }; // end of monkeypatching -module.exports = request; \ No newline at end of file +module.exports = request; diff --git a/package.json b/package.json index 3a6809ad6..50cc117d0 100644 --- a/package.json +++ b/package.json @@ -44,13 +44,12 @@ "@iobroker/adapter-core": "^3.1.6", "@types/node": "^20.14.12", "@types/request": "^2.48.12", - "axios": "^1.7.2", + "axios": "^1.7.3", "jsonata": "^2.0.5", "jszip": "^3.10.1", "node-inspect": "^2.0.0", "node-schedule": "2.1.1", "promisify-child-process": "^4.1.2", - "request": "^2.88.2", "semver": "^7.6.3", "suncalc2": "^1.8.1", "typescript": "~5.5.4", @@ -64,9 +63,10 @@ "@alcalzone/release-script-plugin-manual-review": "^3.7.0", "@iobroker/adapter-dev": "^1.3.0", "@iobroker/types": "^6.0.9", - "@iobroker/vis-2-widgets-react-dev": "^2.0.2", + "@iobroker/vis-2-widgets-react-dev": "^3.0.7", "alcalzone-shared": "^4.0.8", - "chai": "^4.4.1", + "request": "^2.88.2", + "chai": "^4.5.0", "eslint": "^8.57.0", "gulp": "^4.0.2", "gulp-rename": "^2.0.0", @@ -92,6 +92,7 @@ "scripts": { "test:declarations": "tsc -p test/lib/TS/tsconfig.json && tsc -p test/lib/JS/tsconfig.json", "test:javascript": "node node_modules/mocha/bin/mocha --exit", + "test:request": "node node_modules/mocha/bin/mocha test/testRequest.js --exit", "test": "npm run test:declarations && npm run test:javascript", "translate": "translate-adapter", "//postinstall": "node ./install/installTypings.js", diff --git a/src-admin/package.json b/src-admin/package.json index 2bd644e8f..fa38ddf5b 100644 --- a/src-admin/package.json +++ b/src-admin/package.json @@ -11,9 +11,9 @@ "@craco/craco": "^7.1.0", "@iobroker/adapter-react-v5": "^6.1.6", "@iobroker/json-config": "^7.0.22", - "@mui/icons-material": "^5.16.5", - "@mui/material": "^5.16.5", - "@mui/x-date-pickers": "^7.11.1", + "@mui/icons-material": "^5.16.6", + "@mui/material": "^5.16.6", + "@mui/x-date-pickers": "^7.12.0", "@originjs/vite-plugin-federation": "^1.3.5", "@rollup/plugin-babel": "^6.0.4", "@rollup/plugin-commonjs": "^26.0.1", @@ -30,7 +30,7 @@ "eslint-plugin-import": "^2.29.1", "eslint-plugin-jsx-a11y": "^6.9.0", "eslint-plugin-only-warn": "^1.1.0", - "eslint-plugin-react": "^7.34.4", + "eslint-plugin-react": "^7.35.0", "eslint-plugin-react-hooks": "^4.6.2", "leaflet": "^1.9.4", "prop-types": "^15.8.1", @@ -53,4 +53,4 @@ "last 1 safari version" ] } -} \ No newline at end of file +} diff --git a/src/package.json b/src/package.json index 8263c4fd4..fd3b39962 100644 --- a/src/package.json +++ b/src/package.json @@ -8,12 +8,12 @@ "@esbuild-plugins/node-globals-polyfill": "^0.2.3", "@icons/material": "^0.4.1", "@iobroker/adapter-react-v5": "^6.1.6", - "@iobroker/type-detector": "^3.0.5", - "@mui/icons-material": "^5.16.5", + "@iobroker/type-detector": "^4.0.1", + "@mui/icons-material": "^5.16.6", "@devbookhq/splitter": "^1.4.2", "@mui/material": "^5.16.6", - "@mui/x-date-pickers": "^7.11.1", - "@sentry/browser": "^8.20.0", + "@mui/x-date-pickers": "^7.12.0", + "@sentry/browser": "^8.22.0", "craco-module-federation": "^1.1.0", "eslint": "^8.57.0", "eslint-config-airbnb": "^19.0.4", @@ -24,12 +24,12 @@ "eslint-plugin-import": "^2.29.1", "eslint-plugin-jsx-a11y": "^6.9.0", "eslint-plugin-only-warn": "^1.1.0", - "eslint-plugin-prettier": "^5.1.3", - "eslint-plugin-react": "^7.34.3", + "eslint-plugin-prettier": "^5.2.1", + "eslint-plugin-react": "^7.35.0", "eslint-plugin-react-hooks": "^4.6.2", "lodash": "^4.17.21", "monaco-editor": "~0.50.0", - "openai": "^4.53.2", + "openai": "^4.54.0", "react": "^18.3.1", "react-ace": "^12.0.0", "react-bem-helper": "^1.4.1", @@ -39,7 +39,7 @@ "react-dom": "^18.3.1", "react-dropzone": "^14.2.3", "react-fullscreen": "^0.1.0", - "react-i18next": "^14.1.2", + "react-i18next": "^15.0.0", "react-icons": "^5.2.1", "react-inlinesvg": "^4.1.3", "react-json-view": "^1.21.3", @@ -70,4 +70,4 @@ "not ie <= 11", "not op_mini all" ] -} \ No newline at end of file +} diff --git a/test/testRequest.js b/test/testRequest.js new file mode 100644 index 000000000..abaa69b13 --- /dev/null +++ b/test/testRequest.js @@ -0,0 +1,183 @@ +const expect = require('chai').expect; +const http = require('http'); +const request = require('../lib/request'); +const realRequest = require('request'); + +const URL = 'http://127.0.0.1:9009'; + +request.setLogger({ + error: () => { }, + warn: () => { }, + info: () => { }, + debug: () => { }, +}); + +function createServer(options) { + options = options || {}; + const app = http.createServer((req, res) => { + if (options.auth) { + const auth = req.headers.authorization; + if (!auth || auth !== `Basic ${Buffer.from(`${options.auth.user}:${options.auth.pass}`).toString('base64')}`) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end('{"error": "Unauthorized"}'); + return; + } + } + if (req.method === 'POST' || req.method === 'PUT' || req.method === 'PATCH') { + let body = ''; + req.on('data', (data) => { + body += data; + }); + // mirror answer + req.on('end', () => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(body); + }); + return; + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{"message": "Hello, world!"}'); + }); + + return new Promise((resolve) => { + app.on('listening', () => { + resolve(app); + }); + app.listen(options.port || 9009, '127.0.0.1'); + }); +} +let server; + +describe('Request', () => { + before(async () => { + server = await createServer(); + }); + it('simple get', (done) => { + // request('http://localhost:9009/vis-material-widgets/translations/en.json', (error, response, body) => { + realRequest(URL, (rError, rResponse, rBody) => { + expect(rError).to.be.null; + request(URL, (error, response, body) => { + expect(error).to.be.null; + expect(response.statusCode).to.equal(rResponse.statusCode); + expect(body).to.equal(rBody); + done(); + }); + }); + }); + + it('simple JSON get', (done) => { + // request('http://localhost:9009/vis-material-widgets/translations/en.json', (error, response, body) => { + const options = { json: true }; + realRequest(URL, options, (rError, rResponse, rBody) => { + expect(rError).to.be.null; + request(URL, options, (error, response, body) => { + expect(error).to.be.null; + expect(response.statusCode).to.equal(rResponse.statusCode); + expect(JSON.stringify(body)).to.equal(JSON.stringify(rBody)); + done(); + }); + }); + }); + + it('get method', (done) => { + // request('http://localhost:9009/vis-material-widgets/translations/en.json', (error, response, body) => { + const options = { url: URL, method: 'get' }; + realRequest(options, (rError, rResponse, rBody) => { + expect(rError).to.be.null; + request(options, (error, response, body) => { + expect(error).to.be.null; + expect(response.statusCode).to.equal(rResponse.statusCode); + expect(JSON.stringify(body)).to.equal(JSON.stringify(rBody)); + done(); + }); + }); + }); + + it('get method with auth', (done) => { + // request('http://localhost:9009/vis-material-widgets/translations/en.json', (error, response, body) => { + const options = { url: 'http://127.0.0.1:9010', method: 'get', auth: { user: 'admin', pass: 'admin' } }; + + createServer({ auth: options.auth, port: 9010 }) + .then(_server => realRequest(options, (rError, rResponse, rBody) => { + expect(rError).to.be.null; + request(options, (error, response, body) => { + expect(error).to.be.null; + expect(response.statusCode).to.equal(rResponse.statusCode); + expect(JSON.stringify(body)).to.equal(JSON.stringify(rBody)); + _server.close(); + done(); + }); + })); + }); + + it('get error', (done) => { + // request('http://localhost:9009/vis-material-widgets/translations/en.json', (error, response, body) => { + realRequest('http://127.0.0.1:9019', (rError, rResponse, rBody) => { + expect(rError).to.be.not.null; + expect(rResponse).to.be.undefined; + expect(rBody).to.be.undefined; + request('http://127.0.0.1:9019', (error, response, body) => { + expect(error).to.be.not.null; + expect(error.code).to.be.equal(rError.code); + expect(error.message).to.be.equal(rError.message); + expect(error.address).to.be.equal(rError.address); + expect(error.port).to.be.equal(rError.port); + expect(response).to.be.undefined; + expect(body).to.be.undefined; + done(); + }); + }); + }); + + it('post form', (done) => { + // request('http://localhost:9009/vis-material-widgets/translations/en.json', (error, response, body) => { + const options = { form: { key: 'value', key2: 'value2' } }; + + realRequest.post(URL, options, (rError, rResponse, rBody) => { + expect(rError).to.be.null; + request.post(URL, options, (error, response, body) => { + expect(error).to.be.null; + expect(response.statusCode).to.equal(rResponse.statusCode); + expect(body).to.equal(rBody); + done(); + }); + }); + }); + + it('post formData', (done) => { + // request('http://localhost:9009/vis-material-widgets/translations/en.json', (error, response, body) => { + const options = { formData: { key: 'value', key2: 'value2' } }; + + realRequest.post(URL, options, (rError, rResponse, rBody) => { + expect(rError).to.be.null; + request.post(URL, options, (error, response, body) => { + expect(error).to.be.null; + expect(response.statusCode).to.equal(rResponse.statusCode); + // expect(body).to.equal(rBody); + done(); + }); + }); + }); + + it('post json', (done) => { + // request('http://localhost:9009/vis-material-widgets/translations/en.json', (error, response, body) => { + const options = { json: { key: 'value', key2: 'value2' } }; + + realRequest.post(URL, options, (rError, rResponse, rBody) => { + expect(rError).to.be.null; + request.post(URL, options, (error, response, body) => { + expect(error).to.be.null; + expect(response.statusCode).to.equal(rResponse.statusCode); + expect(JSON.stringify(body)).to.equal(JSON.stringify(rBody)); + done(); + }); + }); + }); + + + + after(() => { + server.close(); + }); +});