From 8c3f54123c31a1769ae5f3c2b1cbe534273fb696 Mon Sep 17 00:00:00 2001 From: * <*> Date: Fri, 1 Sep 2023 09:10:15 +0800 Subject: [PATCH] Match-id-b825e396966b6401befa20710d950b882786d3b1 --- packages/horizon-request/.eslintrc.js | 59 + packages/horizon-request/.gitignore | 2 + packages/horizon-request/.npmignore | 1 + packages/horizon-request/.prettierrc.js | 17 + packages/horizon-request/README.md | 1245 +++++++++++++++++ packages/horizon-request/babel.config.js | 18 + packages/horizon-request/coverage/clover.xml | 6 + .../coverage/coverage-final.json | 1 + .../coverage/lcov-report/base.css | 224 +++ .../coverage/lcov-report/block-navigation.js | 87 ++ .../coverage/lcov-report/favicon.png | Bin 0 -> 445 bytes .../coverage/lcov-report/index.html | 101 ++ .../coverage/lcov-report/prettify.css | 1 + .../coverage/lcov-report/prettify.js | 2 + .../lcov-report/sort-arrow-sprite.png | Bin 0 -> 138 bytes .../coverage/lcov-report/sorter.js | 196 +++ packages/horizon-request/coverage/lcov.info | 0 .../cancelRequest/cancelRequestTest.html | 58 + .../examples/cancelRequest/cancelStyles.css | 58 + .../interceptor/interceptorStyles.css | 87 ++ .../examples/interceptor/interceptorTest.html | 104 ++ .../examples/request/downloadTest.html | 10 + .../examples/request/requestStyles.css | 86 ++ .../examples/request/requestTest.html | 214 +++ .../examples/server/serverTest.mjs | 93 ++ .../horizon-request/examples/useHR/App .jsx | 36 + .../horizon-request/examples/useHR/index.html | 98 ++ .../horizon-request/examples/useHR/index.jsx | 9 + packages/horizon-request/index.ts | 57 + packages/horizon-request/jest.config.cjs | 25 + packages/horizon-request/nodemon.json | 11 + packages/horizon-request/package.json | 69 + .../horizon-request/rollup.config.example.js | 20 + packages/horizon-request/rollup.config.js | 29 + packages/horizon-request/src/cancel/Cancel.ts | 11 + .../horizon-request/src/cancel/CancelError.ts | 12 + .../horizon-request/src/cancel/CancelToken.ts | 48 + .../horizon-request/src/cancel/checkCancel.ts | 6 + .../src/config/defaultConfig.ts | 28 + .../src/core/HorizonRequest.ts | 211 +++ packages/horizon-request/src/core/HrError.ts | 102 ++ .../horizon-request/src/core/HrHeaders.ts | 227 +++ .../src/core/useHR/HRClient.ts | 146 ++ .../horizon-request/src/core/useHR/useHR.ts | 43 + .../src/dataTransformers/transformRequest.ts | 68 + .../src/dataTransformers/transformResponse.ts | 34 + .../horizon-request/src/horizonRequest.ts | 61 + .../src/interceptor/InterceptorManager.ts | 45 + .../interceptor/getRequestInterceptorsInfo.ts | 26 + .../getResponseInterceptorChain.ts | 13 + .../src/interceptor/handleAsyncInterceptor.ts | 23 + .../src/interceptor/handleSyncInterceptor.ts | 41 + .../src/request/fetchRequest.ts | 166 +++ .../ieCompatibility/CustomAbortController.ts | 19 + .../ieCompatibility/CustomAbortSignal.ts | 30 + .../request/ieCompatibility/CustomHeaders.ts | 35 + .../request/ieCompatibility/CustomResponse.ts | 35 + .../src/request/ieCompatibility/fetchLike.ts | 33 + .../src/request/ieFetchRequest.ts | 149 ++ .../src/request/processDownloadProgress.ts | 31 + .../src/request/processRequest.ts | 54 + .../src/request/processUploadProgress.ts | 94 ++ .../horizon-request/src/types/interfaces.ts | 282 ++++ packages/horizon-request/src/types/types.ts | 47 + .../src/utils/commonUtils/utils.ts | 454 ++++++ .../src/utils/configUtils/deepMerge.ts | 34 + .../src/utils/configUtils/getMergedConfig.ts | 40 + .../src/utils/dataUtils/getFormData.ts | 19 + .../src/utils/dataUtils/getJSONByFormData.ts | 28 + .../src/utils/dataUtils/transformData.ts | 16 + .../src/utils/headerUtils/checkHeaderName.ts | 5 + .../utils/headerUtils/convertRawHeaders.ts | 32 + .../src/utils/headerUtils/deleteHeader.ts | 18 + .../utils/headerUtils/processValueByParser.ts | 35 + .../src/utils/instanceUtils/buildInstance.ts | 15 + .../src/utils/instanceUtils/extendInstance.ts | 11 + .../unitTest/utils/commonUtils/bind.test.ts | 28 + .../commonUtils/convertToCamelCase.test.ts | 45 + .../commonUtils/createTypeChecker.test.ts | 107 ++ .../utils/commonUtils/extendObject.test.ts | 31 + .../utils/commonUtils/flattenObject.test.ts | 30 + .../utils/commonUtils/forEach.test.ts | 41 + .../utils/commonUtils/forEachEntry.test.ts | 34 + .../commonUtils/getNormalizedValue.test.ts | 20 + .../commonUtils/getObjectByArray.test.ts | 32 + .../utils/commonUtils/getObjectKey.test.ts | 23 + .../utils/commonUtils/getType.test.ts | 38 + .../commonUtils/objectToQueryString.test.ts | 45 + .../utils/commonUtils/stringifySafely.test.ts | 33 + .../utils/commonUtils/toBooleanObject.test.ts | 45 + .../utils/commonUtils/toJSONSafe.test.ts | 26 + .../utils/configUtils/deepMerge.test.ts | 26 + .../utils/configUtils/getMergedConfig.test.ts | 51 + .../utils/dataUtils/getFormData.test.ts | 52 + .../utils/dataUtils/getJSONByFormData.test.ts | 31 + .../utils/dataUtils/parsePath.test.ts | 10 + .../utils/headerUtils/checkHeaderName.test.ts | 21 + .../headerUtils/convertRawHeaders.test.ts | 46 + .../utils/headerUtils/deleteHeader.test.ts | 35 + .../headerUtils/parseKeyValuePairs.test.ts | 65 + .../headerUtils/processValueByParser.test.ts | 49 + packages/horizon-request/tsconfig.json | 20 + packages/horizon-request/webpack.config.js | 44 + .../horizon-request/webpack.useHR.config.js | 49 + 104 files changed, 6928 insertions(+) create mode 100644 packages/horizon-request/.eslintrc.js create mode 100644 packages/horizon-request/.gitignore create mode 100644 packages/horizon-request/.npmignore create mode 100644 packages/horizon-request/.prettierrc.js create mode 100644 packages/horizon-request/README.md create mode 100644 packages/horizon-request/babel.config.js create mode 100644 packages/horizon-request/coverage/clover.xml create mode 100644 packages/horizon-request/coverage/coverage-final.json create mode 100644 packages/horizon-request/coverage/lcov-report/base.css create mode 100644 packages/horizon-request/coverage/lcov-report/block-navigation.js create mode 100644 packages/horizon-request/coverage/lcov-report/favicon.png create mode 100644 packages/horizon-request/coverage/lcov-report/index.html create mode 100644 packages/horizon-request/coverage/lcov-report/prettify.css create mode 100644 packages/horizon-request/coverage/lcov-report/prettify.js create mode 100644 packages/horizon-request/coverage/lcov-report/sort-arrow-sprite.png create mode 100644 packages/horizon-request/coverage/lcov-report/sorter.js create mode 100644 packages/horizon-request/coverage/lcov.info create mode 100644 packages/horizon-request/examples/cancelRequest/cancelRequestTest.html create mode 100644 packages/horizon-request/examples/cancelRequest/cancelStyles.css create mode 100644 packages/horizon-request/examples/interceptor/interceptorStyles.css create mode 100644 packages/horizon-request/examples/interceptor/interceptorTest.html create mode 100644 packages/horizon-request/examples/request/downloadTest.html create mode 100644 packages/horizon-request/examples/request/requestStyles.css create mode 100644 packages/horizon-request/examples/request/requestTest.html create mode 100644 packages/horizon-request/examples/server/serverTest.mjs create mode 100644 packages/horizon-request/examples/useHR/App .jsx create mode 100644 packages/horizon-request/examples/useHR/index.html create mode 100644 packages/horizon-request/examples/useHR/index.jsx create mode 100644 packages/horizon-request/index.ts create mode 100644 packages/horizon-request/jest.config.cjs create mode 100644 packages/horizon-request/nodemon.json create mode 100644 packages/horizon-request/package.json create mode 100644 packages/horizon-request/rollup.config.example.js create mode 100644 packages/horizon-request/rollup.config.js create mode 100644 packages/horizon-request/src/cancel/Cancel.ts create mode 100644 packages/horizon-request/src/cancel/CancelError.ts create mode 100644 packages/horizon-request/src/cancel/CancelToken.ts create mode 100644 packages/horizon-request/src/cancel/checkCancel.ts create mode 100644 packages/horizon-request/src/config/defaultConfig.ts create mode 100644 packages/horizon-request/src/core/HorizonRequest.ts create mode 100644 packages/horizon-request/src/core/HrError.ts create mode 100644 packages/horizon-request/src/core/HrHeaders.ts create mode 100644 packages/horizon-request/src/core/useHR/HRClient.ts create mode 100644 packages/horizon-request/src/core/useHR/useHR.ts create mode 100644 packages/horizon-request/src/dataTransformers/transformRequest.ts create mode 100644 packages/horizon-request/src/dataTransformers/transformResponse.ts create mode 100644 packages/horizon-request/src/horizonRequest.ts create mode 100644 packages/horizon-request/src/interceptor/InterceptorManager.ts create mode 100644 packages/horizon-request/src/interceptor/getRequestInterceptorsInfo.ts create mode 100644 packages/horizon-request/src/interceptor/getResponseInterceptorChain.ts create mode 100644 packages/horizon-request/src/interceptor/handleAsyncInterceptor.ts create mode 100644 packages/horizon-request/src/interceptor/handleSyncInterceptor.ts create mode 100644 packages/horizon-request/src/request/fetchRequest.ts create mode 100644 packages/horizon-request/src/request/ieCompatibility/CustomAbortController.ts create mode 100644 packages/horizon-request/src/request/ieCompatibility/CustomAbortSignal.ts create mode 100644 packages/horizon-request/src/request/ieCompatibility/CustomHeaders.ts create mode 100644 packages/horizon-request/src/request/ieCompatibility/CustomResponse.ts create mode 100644 packages/horizon-request/src/request/ieCompatibility/fetchLike.ts create mode 100644 packages/horizon-request/src/request/ieFetchRequest.ts create mode 100644 packages/horizon-request/src/request/processDownloadProgress.ts create mode 100644 packages/horizon-request/src/request/processRequest.ts create mode 100644 packages/horizon-request/src/request/processUploadProgress.ts create mode 100644 packages/horizon-request/src/types/interfaces.ts create mode 100644 packages/horizon-request/src/types/types.ts create mode 100644 packages/horizon-request/src/utils/commonUtils/utils.ts create mode 100644 packages/horizon-request/src/utils/configUtils/deepMerge.ts create mode 100644 packages/horizon-request/src/utils/configUtils/getMergedConfig.ts create mode 100644 packages/horizon-request/src/utils/dataUtils/getFormData.ts create mode 100644 packages/horizon-request/src/utils/dataUtils/getJSONByFormData.ts create mode 100644 packages/horizon-request/src/utils/dataUtils/transformData.ts create mode 100644 packages/horizon-request/src/utils/headerUtils/checkHeaderName.ts create mode 100644 packages/horizon-request/src/utils/headerUtils/convertRawHeaders.ts create mode 100644 packages/horizon-request/src/utils/headerUtils/deleteHeader.ts create mode 100644 packages/horizon-request/src/utils/headerUtils/processValueByParser.ts create mode 100644 packages/horizon-request/src/utils/instanceUtils/buildInstance.ts create mode 100644 packages/horizon-request/src/utils/instanceUtils/extendInstance.ts create mode 100644 packages/horizon-request/tests/unitTest/utils/commonUtils/bind.test.ts create mode 100644 packages/horizon-request/tests/unitTest/utils/commonUtils/convertToCamelCase.test.ts create mode 100644 packages/horizon-request/tests/unitTest/utils/commonUtils/createTypeChecker.test.ts create mode 100644 packages/horizon-request/tests/unitTest/utils/commonUtils/extendObject.test.ts create mode 100644 packages/horizon-request/tests/unitTest/utils/commonUtils/flattenObject.test.ts create mode 100644 packages/horizon-request/tests/unitTest/utils/commonUtils/forEach.test.ts create mode 100644 packages/horizon-request/tests/unitTest/utils/commonUtils/forEachEntry.test.ts create mode 100644 packages/horizon-request/tests/unitTest/utils/commonUtils/getNormalizedValue.test.ts create mode 100644 packages/horizon-request/tests/unitTest/utils/commonUtils/getObjectByArray.test.ts create mode 100644 packages/horizon-request/tests/unitTest/utils/commonUtils/getObjectKey.test.ts create mode 100644 packages/horizon-request/tests/unitTest/utils/commonUtils/getType.test.ts create mode 100644 packages/horizon-request/tests/unitTest/utils/commonUtils/objectToQueryString.test.ts create mode 100644 packages/horizon-request/tests/unitTest/utils/commonUtils/stringifySafely.test.ts create mode 100644 packages/horizon-request/tests/unitTest/utils/commonUtils/toBooleanObject.test.ts create mode 100644 packages/horizon-request/tests/unitTest/utils/commonUtils/toJSONSafe.test.ts create mode 100644 packages/horizon-request/tests/unitTest/utils/configUtils/deepMerge.test.ts create mode 100644 packages/horizon-request/tests/unitTest/utils/configUtils/getMergedConfig.test.ts create mode 100644 packages/horizon-request/tests/unitTest/utils/dataUtils/getFormData.test.ts create mode 100644 packages/horizon-request/tests/unitTest/utils/dataUtils/getJSONByFormData.test.ts create mode 100644 packages/horizon-request/tests/unitTest/utils/dataUtils/parsePath.test.ts create mode 100644 packages/horizon-request/tests/unitTest/utils/headerUtils/checkHeaderName.test.ts create mode 100644 packages/horizon-request/tests/unitTest/utils/headerUtils/convertRawHeaders.test.ts create mode 100644 packages/horizon-request/tests/unitTest/utils/headerUtils/deleteHeader.test.ts create mode 100644 packages/horizon-request/tests/unitTest/utils/headerUtils/parseKeyValuePairs.test.ts create mode 100644 packages/horizon-request/tests/unitTest/utils/headerUtils/processValueByParser.test.ts create mode 100644 packages/horizon-request/tsconfig.json create mode 100644 packages/horizon-request/webpack.config.js create mode 100644 packages/horizon-request/webpack.useHR.config.js diff --git a/packages/horizon-request/.eslintrc.js b/packages/horizon-request/.eslintrc.js new file mode 100644 index 00000000..46f2b651 --- /dev/null +++ b/packages/horizon-request/.eslintrc.js @@ -0,0 +1,59 @@ +module.exports = { + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/eslint-recommended', + 'plugin:@typescript-eslint/recommended', + 'prettier', + ], + root: true, + + plugins: ['jest', 'no-for-of-loops', 'no-function-declare-after-return', 'react', '@typescript-eslint'], + + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 8, + sourceType: 'module', + ecmaFeatures: { + jsx: true, + modules: true, + experimentalObjectRestSpread: true, + }, + }, + env: { + browser: true, + jest: true, + node: true, + es6: true, + }, + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/no-empty-function': 'off', + semi: ['warn', 'always'], + quotes: ['warn', 'single'], + 'accessor-pairs': 'off', + 'brace-style': ['error', '1tbs'], + 'func-style': ['warn', 'declaration', { allowArrowFunctions: true }], + 'max-lines-per-function': 'off', + 'object-curly-newline': 'off', + // 尾随逗号 + 'comma-dangle': ['error', 'only-multiline'], + 'prefer-const': 'off', + 'no-constant-condition': 'off', + 'no-for-of-loops/no-for-of-loops': 'error', + 'no-function-declare-after-return/no-function-declare-after-return': 'error', + }, + globals: { + isDev: true, + isTest: true, + }, + overrides: [ + { + files: ['scripts/__tests__/**/*.js'], + globals: { + container: true, + }, + }, + ], +}; diff --git a/packages/horizon-request/.gitignore b/packages/horizon-request/.gitignore new file mode 100644 index 00000000..b0a5c349 --- /dev/null +++ b/packages/horizon-request/.gitignore @@ -0,0 +1,2 @@ +/node_modules/ +/dist/ diff --git a/packages/horizon-request/.npmignore b/packages/horizon-request/.npmignore new file mode 100644 index 00000000..b512c09d --- /dev/null +++ b/packages/horizon-request/.npmignore @@ -0,0 +1 @@ +node_modules \ No newline at end of file diff --git a/packages/horizon-request/.prettierrc.js b/packages/horizon-request/.prettierrc.js new file mode 100644 index 00000000..18746c04 --- /dev/null +++ b/packages/horizon-request/.prettierrc.js @@ -0,0 +1,17 @@ +'use strict'; + +module.exports = { + printWidth: 120, // 一行120字符数,如果超过会进行换行 + tabWidth: 2, // tab等2个空格 + useTabs: false, // 用空格缩进行 + semi: true, // 行尾使用分号 + singleQuote: true, // 字符串使用单引号 + quoteProps: 'as-needed', // 仅在需要时在对象属性添加引号 + jsxSingleQuote: false, // 在JSX中使用双引号 + trailingComma: 'es5', // 使用尾逗号(对象、数组等) + bracketSpacing: true, // 对象的括号间增加空格 + bracketSameLine: false, // 将多行JSX元素的>放在最后一行的末尾 + arrowParens: 'avoid', // 在唯一的arrow函数参数周围省略括号 + vueIndentScriptAndStyle: false, // 不缩进Vue文件中的 + + + + + + \ No newline at end of file diff --git a/packages/horizon-request/coverage/lcov-report/prettify.css b/packages/horizon-request/coverage/lcov-report/prettify.css new file mode 100644 index 00000000..b317a7cd --- /dev/null +++ b/packages/horizon-request/coverage/lcov-report/prettify.css @@ -0,0 +1 @@ +.pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee} diff --git a/packages/horizon-request/coverage/lcov-report/prettify.js b/packages/horizon-request/coverage/lcov-report/prettify.js new file mode 100644 index 00000000..b3225238 --- /dev/null +++ b/packages/horizon-request/coverage/lcov-report/prettify.js @@ -0,0 +1,2 @@ +/* eslint-disable */ +window.PR_SHOULD_USE_CONTINUATION=true;(function(){var h=["break,continue,do,else,for,if,return,while"];var u=[h,"auto,case,char,const,default,double,enum,extern,float,goto,int,long,register,short,signed,sizeof,static,struct,switch,typedef,union,unsigned,void,volatile"];var p=[u,"catch,class,delete,false,import,new,operator,private,protected,public,this,throw,true,try,typeof"];var l=[p,"alignof,align_union,asm,axiom,bool,concept,concept_map,const_cast,constexpr,decltype,dynamic_cast,explicit,export,friend,inline,late_check,mutable,namespace,nullptr,reinterpret_cast,static_assert,static_cast,template,typeid,typename,using,virtual,where"];var x=[p,"abstract,boolean,byte,extends,final,finally,implements,import,instanceof,null,native,package,strictfp,super,synchronized,throws,transient"];var R=[x,"as,base,by,checked,decimal,delegate,descending,dynamic,event,fixed,foreach,from,group,implicit,in,interface,internal,into,is,lock,object,out,override,orderby,params,partial,readonly,ref,sbyte,sealed,stackalloc,string,select,uint,ulong,unchecked,unsafe,ushort,var"];var r="all,and,by,catch,class,else,extends,false,finally,for,if,in,is,isnt,loop,new,no,not,null,of,off,on,or,return,super,then,true,try,unless,until,when,while,yes";var w=[p,"debugger,eval,export,function,get,null,set,undefined,var,with,Infinity,NaN"];var s="caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END";var I=[h,"and,as,assert,class,def,del,elif,except,exec,finally,from,global,import,in,is,lambda,nonlocal,not,or,pass,print,raise,try,with,yield,False,True,None"];var f=[h,"alias,and,begin,case,class,def,defined,elsif,end,ensure,false,in,module,next,nil,not,or,redo,rescue,retry,self,super,then,true,undef,unless,until,when,yield,BEGIN,END"];var H=[h,"case,done,elif,esac,eval,fi,function,in,local,set,then,until"];var A=[l,R,w,s+I,f,H];var e=/^(DIR|FILE|vector|(de|priority_)?queue|list|stack|(const_)?iterator|(multi)?(set|map)|bitset|u?(int|float)\d*)/;var C="str";var z="kwd";var j="com";var O="typ";var G="lit";var L="pun";var F="pln";var m="tag";var E="dec";var J="src";var P="atn";var n="atv";var N="nocode";var M="(?:^^\\.?|[+-]|\\!|\\!=|\\!==|\\#|\\%|\\%=|&|&&|&&=|&=|\\(|\\*|\\*=|\\+=|\\,|\\-=|\\->|\\/|\\/=|:|::|\\;|<|<<|<<=|<=|=|==|===|>|>=|>>|>>=|>>>|>>>=|\\?|\\@|\\[|\\^|\\^=|\\^\\^|\\^\\^=|\\{|\\||\\|=|\\|\\||\\|\\|=|\\~|break|case|continue|delete|do|else|finally|instanceof|return|throw|try|typeof)\\s*";function k(Z){var ad=0;var S=false;var ac=false;for(var V=0,U=Z.length;V122)){if(!(al<65||ag>90)){af.push([Math.max(65,ag)|32,Math.min(al,90)|32])}if(!(al<97||ag>122)){af.push([Math.max(97,ag)&~32,Math.min(al,122)&~32])}}}}af.sort(function(av,au){return(av[0]-au[0])||(au[1]-av[1])});var ai=[];var ap=[NaN,NaN];for(var ar=0;arat[0]){if(at[1]+1>at[0]){an.push("-")}an.push(T(at[1]))}}an.push("]");return an.join("")}function W(al){var aj=al.source.match(new RegExp("(?:\\[(?:[^\\x5C\\x5D]|\\\\[\\s\\S])*\\]|\\\\u[A-Fa-f0-9]{4}|\\\\x[A-Fa-f0-9]{2}|\\\\[0-9]+|\\\\[^ux0-9]|\\(\\?[:!=]|[\\(\\)\\^]|[^\\x5B\\x5C\\(\\)\\^]+)","g"));var ah=aj.length;var an=[];for(var ak=0,am=0;ak=2&&ai==="["){aj[ak]=X(ag)}else{if(ai!=="\\"){aj[ak]=ag.replace(/[a-zA-Z]/g,function(ao){var ap=ao.charCodeAt(0);return"["+String.fromCharCode(ap&~32,ap|32)+"]"})}}}}return aj.join("")}var aa=[];for(var V=0,U=Z.length;V=0;){S[ac.charAt(ae)]=Y}}var af=Y[1];var aa=""+af;if(!ag.hasOwnProperty(aa)){ah.push(af);ag[aa]=null}}ah.push(/[\0-\uffff]/);V=k(ah)})();var X=T.length;var W=function(ah){var Z=ah.sourceCode,Y=ah.basePos;var ad=[Y,F];var af=0;var an=Z.match(V)||[];var aj={};for(var ae=0,aq=an.length;ae=5&&"lang-"===ap.substring(0,5);if(am&&!(ai&&typeof ai[1]==="string")){am=false;ap=J}if(!am){aj[ag]=ap}}var ab=af;af+=ag.length;if(!am){ad.push(Y+ab,ap)}else{var al=ai[1];var ak=ag.indexOf(al);var ac=ak+al.length;if(ai[2]){ac=ag.length-ai[2].length;ak=ac-al.length}var ar=ap.substring(5);B(Y+ab,ag.substring(0,ak),W,ad);B(Y+ab+ak,al,q(ar,al),ad);B(Y+ab+ac,ag.substring(ac),W,ad)}}ah.decorations=ad};return W}function i(T){var W=[],S=[];if(T.tripleQuotedStrings){W.push([C,/^(?:\'\'\'(?:[^\'\\]|\\[\s\S]|\'{1,2}(?=[^\']))*(?:\'\'\'|$)|\"\"\"(?:[^\"\\]|\\[\s\S]|\"{1,2}(?=[^\"]))*(?:\"\"\"|$)|\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$))/,null,"'\""])}else{if(T.multiLineStrings){W.push([C,/^(?:\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$)|\`(?:[^\\\`]|\\[\s\S])*(?:\`|$))/,null,"'\"`"])}else{W.push([C,/^(?:\'(?:[^\\\'\r\n]|\\.)*(?:\'|$)|\"(?:[^\\\"\r\n]|\\.)*(?:\"|$))/,null,"\"'"])}}if(T.verbatimStrings){S.push([C,/^@\"(?:[^\"]|\"\")*(?:\"|$)/,null])}var Y=T.hashComments;if(Y){if(T.cStyleComments){if(Y>1){W.push([j,/^#(?:##(?:[^#]|#(?!##))*(?:###|$)|.*)/,null,"#"])}else{W.push([j,/^#(?:(?:define|elif|else|endif|error|ifdef|include|ifndef|line|pragma|undef|warning)\b|[^\r\n]*)/,null,"#"])}S.push([C,/^<(?:(?:(?:\.\.\/)*|\/?)(?:[\w-]+(?:\/[\w-]+)+)?[\w-]+\.h|[a-z]\w*)>/,null])}else{W.push([j,/^#[^\r\n]*/,null,"#"])}}if(T.cStyleComments){S.push([j,/^\/\/[^\r\n]*/,null]);S.push([j,/^\/\*[\s\S]*?(?:\*\/|$)/,null])}if(T.regexLiterals){var X=("/(?=[^/*])(?:[^/\\x5B\\x5C]|\\x5C[\\s\\S]|\\x5B(?:[^\\x5C\\x5D]|\\x5C[\\s\\S])*(?:\\x5D|$))+/");S.push(["lang-regex",new RegExp("^"+M+"("+X+")")])}var V=T.types;if(V){S.push([O,V])}var U=(""+T.keywords).replace(/^ | $/g,"");if(U.length){S.push([z,new RegExp("^(?:"+U.replace(/[\s,]+/g,"|")+")\\b"),null])}W.push([F,/^\s+/,null," \r\n\t\xA0"]);S.push([G,/^@[a-z_$][a-z_$@0-9]*/i,null],[O,/^(?:[@_]?[A-Z]+[a-z][A-Za-z_$@0-9]*|\w+_t\b)/,null],[F,/^[a-z_$][a-z_$@0-9]*/i,null],[G,new RegExp("^(?:0x[a-f0-9]+|(?:\\d(?:_\\d+)*\\d*(?:\\.\\d*)?|\\.\\d\\+)(?:e[+\\-]?\\d+)?)[a-z]*","i"),null,"0123456789"],[F,/^\\[\s\S]?/,null],[L,/^.[^\s\w\.$@\'\"\`\/\#\\]*/,null]);return g(W,S)}var K=i({keywords:A,hashComments:true,cStyleComments:true,multiLineStrings:true,regexLiterals:true});function Q(V,ag){var U=/(?:^|\s)nocode(?:\s|$)/;var ab=/\r\n?|\n/;var ac=V.ownerDocument;var S;if(V.currentStyle){S=V.currentStyle.whiteSpace}else{if(window.getComputedStyle){S=ac.defaultView.getComputedStyle(V,null).getPropertyValue("white-space")}}var Z=S&&"pre"===S.substring(0,3);var af=ac.createElement("LI");while(V.firstChild){af.appendChild(V.firstChild)}var W=[af];function ae(al){switch(al.nodeType){case 1:if(U.test(al.className)){break}if("BR"===al.nodeName){ad(al);if(al.parentNode){al.parentNode.removeChild(al)}}else{for(var an=al.firstChild;an;an=an.nextSibling){ae(an)}}break;case 3:case 4:if(Z){var am=al.nodeValue;var aj=am.match(ab);if(aj){var ai=am.substring(0,aj.index);al.nodeValue=ai;var ah=am.substring(aj.index+aj[0].length);if(ah){var ak=al.parentNode;ak.insertBefore(ac.createTextNode(ah),al.nextSibling)}ad(al);if(!ai){al.parentNode.removeChild(al)}}}break}}function ad(ak){while(!ak.nextSibling){ak=ak.parentNode;if(!ak){return}}function ai(al,ar){var aq=ar?al.cloneNode(false):al;var ao=al.parentNode;if(ao){var ap=ai(ao,1);var an=al.nextSibling;ap.appendChild(aq);for(var am=an;am;am=an){an=am.nextSibling;ap.appendChild(am)}}return aq}var ah=ai(ak.nextSibling,0);for(var aj;(aj=ah.parentNode)&&aj.nodeType===1;){ah=aj}W.push(ah)}for(var Y=0;Y=S){ah+=2}if(V>=ap){Z+=2}}}var t={};function c(U,V){for(var S=V.length;--S>=0;){var T=V[S];if(!t.hasOwnProperty(T)){t[T]=U}else{if(window.console){console.warn("cannot override language handler %s",T)}}}}function q(T,S){if(!(T&&t.hasOwnProperty(T))){T=/^\s*]*(?:>|$)/],[j,/^<\!--[\s\S]*?(?:-\->|$)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],[L,/^(?:<[%?]|[%?]>)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i]]),["default-markup","htm","html","mxml","xhtml","xml","xsl"]);c(g([[F,/^[\s]+/,null," \t\r\n"],[n,/^(?:\"[^\"]*\"?|\'[^\']*\'?)/,null,"\"'"]],[[m,/^^<\/?[a-z](?:[\w.:-]*\w)?|\/?>$/i],[P,/^(?!style[\s=]|on)[a-z](?:[\w:-]*\w)?/i],["lang-uq.val",/^=\s*([^>\'\"\s]*(?:[^>\'\"\s\/]|\/(?=\s)))/],[L,/^[=<>\/]+/],["lang-js",/^on\w+\s*=\s*\"([^\"]+)\"/i],["lang-js",/^on\w+\s*=\s*\'([^\']+)\'/i],["lang-js",/^on\w+\s*=\s*([^\"\'>\s]+)/i],["lang-css",/^style\s*=\s*\"([^\"]+)\"/i],["lang-css",/^style\s*=\s*\'([^\']+)\'/i],["lang-css",/^style\s*=\s*([^\"\'>\s]+)/i]]),["in.tag"]);c(g([],[[n,/^[\s\S]+/]]),["uq.val"]);c(i({keywords:l,hashComments:true,cStyleComments:true,types:e}),["c","cc","cpp","cxx","cyc","m"]);c(i({keywords:"null,true,false"}),["json"]);c(i({keywords:R,hashComments:true,cStyleComments:true,verbatimStrings:true,types:e}),["cs"]);c(i({keywords:x,cStyleComments:true}),["java"]);c(i({keywords:H,hashComments:true,multiLineStrings:true}),["bsh","csh","sh"]);c(i({keywords:I,hashComments:true,multiLineStrings:true,tripleQuotedStrings:true}),["cv","py"]);c(i({keywords:s,hashComments:true,multiLineStrings:true,regexLiterals:true}),["perl","pl","pm"]);c(i({keywords:f,hashComments:true,multiLineStrings:true,regexLiterals:true}),["rb"]);c(i({keywords:w,cStyleComments:true,regexLiterals:true}),["js"]);c(i({keywords:r,hashComments:3,cStyleComments:true,multilineStrings:true,tripleQuotedStrings:true,regexLiterals:true}),["coffee"]);c(g([],[[C,/^[\s\S]+/]]),["regex"]);function d(V){var U=V.langExtension;try{var S=a(V.sourceNode);var T=S.sourceCode;V.sourceCode=T;V.spans=S.spans;V.basePos=0;q(U,T)(V);D(V)}catch(W){if("console" in window){console.log(W&&W.stack?W.stack:W)}}}function y(W,V,U){var S=document.createElement("PRE");S.innerHTML=W;if(U){Q(S,U)}var T={langExtension:V,numberLines:U,sourceNode:S};d(T);return S.innerHTML}function b(ad){function Y(af){return document.getElementsByTagName(af)}var ac=[Y("pre"),Y("code"),Y("xmp")];var T=[];for(var aa=0;aa=0){var ah=ai.match(ab);var am;if(!ah&&(am=o(aj))&&"CODE"===am.tagName){ah=am.className.match(ab)}if(ah){ah=ah[1]}var al=false;for(var ak=aj.parentNode;ak;ak=ak.parentNode){if((ak.tagName==="pre"||ak.tagName==="code"||ak.tagName==="xmp")&&ak.className&&ak.className.indexOf("prettyprint")>=0){al=true;break}}if(!al){var af=aj.className.match(/\blinenums\b(?::(\d+))?/);af=af?af[1]&&af[1].length?+af[1]:true:false;if(af){Q(aj,af)}S={langExtension:ah,sourceNode:aj,numberLines:af};d(S)}}}if(X]*(?:>|$)/],[PR.PR_COMMENT,/^<\!--[\s\S]*?(?:-\->|$)/],[PR.PR_PUNCTUATION,/^(?:<[%?]|[%?]>)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-handlebars",/^]*type\s*=\s*['"]?text\/x-handlebars-template['"]?\b[^>]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i],[PR.PR_DECLARATION,/^{{[#^>/]?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{&?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{{>?\s*[\w.][^}]*}}}/],[PR.PR_COMMENT,/^{{![^}]*}}/]]),["handlebars","hbs"]);PR.registerLangHandler(PR.createSimpleLexer([[PR.PR_PLAIN,/^[ \t\r\n\f]+/,null," \t\r\n\f"]],[[PR.PR_STRING,/^\"(?:[^\n\r\f\\\"]|\\(?:\r\n?|\n|\f)|\\[\s\S])*\"/,null],[PR.PR_STRING,/^\'(?:[^\n\r\f\\\']|\\(?:\r\n?|\n|\f)|\\[\s\S])*\'/,null],["lang-css-str",/^url\(([^\)\"\']*)\)/i],[PR.PR_KEYWORD,/^(?:url|rgb|\!important|@import|@page|@media|@charset|inherit)(?=[^\-\w]|$)/i,null],["lang-css-kw",/^(-?(?:[_a-z]|(?:\\[0-9a-f]+ ?))(?:[_a-z0-9\-]|\\(?:\\[0-9a-f]+ ?))*)\s*:/i],[PR.PR_COMMENT,/^\/\*[^*]*\*+(?:[^\/*][^*]*\*+)*\//],[PR.PR_COMMENT,/^(?:)/],[PR.PR_LITERAL,/^(?:\d+|\d*\.\d+)(?:%|[a-z]+)?/i],[PR.PR_LITERAL,/^#(?:[0-9a-f]{3}){1,2}/i],[PR.PR_PLAIN,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i],[PR.PR_PUNCTUATION,/^[^\s\w\'\"]+/]]),["css"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_KEYWORD,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i]]),["css-kw"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_STRING,/^[^\)\"\']+/]]),["css-str"]); diff --git a/packages/horizon-request/coverage/lcov-report/sort-arrow-sprite.png b/packages/horizon-request/coverage/lcov-report/sort-arrow-sprite.png new file mode 100644 index 0000000000000000000000000000000000000000..6ed68316eb3f65dec9063332d2f69bf3093bbfab GIT binary patch literal 138 zcmeAS@N?(olHy`uVBq!ia0vp^>_9Bd!3HEZxJ@+%Qh}Z>jv*C{$p!i!8j}?a+@3A= zIAGwzjijN=FBi!|L1t?LM;Q;gkwn>2cAy-KV{dn nf0J1DIvEHQu*n~6U}x}qyky7vi4|9XhBJ7&`njxgN@xNA8m%nc literal 0 HcmV?d00001 diff --git a/packages/horizon-request/coverage/lcov-report/sorter.js b/packages/horizon-request/coverage/lcov-report/sorter.js new file mode 100644 index 00000000..2bb296a8 --- /dev/null +++ b/packages/horizon-request/coverage/lcov-report/sorter.js @@ -0,0 +1,196 @@ +/* eslint-disable */ +var addSorting = (function() { + 'use strict'; + var cols, + currentSort = { + index: 0, + desc: false + }; + + // returns the summary table element + function getTable() { + return document.querySelector('.coverage-summary'); + } + // returns the thead element of the summary table + function getTableHeader() { + return getTable().querySelector('thead tr'); + } + // returns the tbody element of the summary table + function getTableBody() { + return getTable().querySelector('tbody'); + } + // returns the th element for nth column + function getNthColumn(n) { + return getTableHeader().querySelectorAll('th')[n]; + } + + function onFilterInput() { + const searchValue = document.getElementById('fileSearch').value; + const rows = document.getElementsByTagName('tbody')[0].children; + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + if ( + row.textContent + .toLowerCase() + .includes(searchValue.toLowerCase()) + ) { + row.style.display = ''; + } else { + row.style.display = 'none'; + } + } + } + + // loads the search box + function addSearchBox() { + var template = document.getElementById('filterTemplate'); + var templateClone = template.content.cloneNode(true); + templateClone.getElementById('fileSearch').oninput = onFilterInput; + template.parentElement.appendChild(templateClone); + } + + // loads all columns + function loadColumns() { + var colNodes = getTableHeader().querySelectorAll('th'), + colNode, + cols = [], + col, + i; + + for (i = 0; i < colNodes.length; i += 1) { + colNode = colNodes[i]; + col = { + key: colNode.getAttribute('data-col'), + sortable: !colNode.getAttribute('data-nosort'), + type: colNode.getAttribute('data-type') || 'string' + }; + cols.push(col); + if (col.sortable) { + col.defaultDescSort = col.type === 'number'; + colNode.innerHTML = + colNode.innerHTML + ''; + } + } + return cols; + } + // attaches a data attribute to every tr element with an object + // of data values keyed by column name + function loadRowData(tableRow) { + var tableCols = tableRow.querySelectorAll('td'), + colNode, + col, + data = {}, + i, + val; + for (i = 0; i < tableCols.length; i += 1) { + colNode = tableCols[i]; + col = cols[i]; + val = colNode.getAttribute('data-value'); + if (col.type === 'number') { + val = Number(val); + } + data[col.key] = val; + } + return data; + } + // loads all row data + function loadData() { + var rows = getTableBody().querySelectorAll('tr'), + i; + + for (i = 0; i < rows.length; i += 1) { + rows[i].data = loadRowData(rows[i]); + } + } + // sorts the table using the data for the ith column + function sortByIndex(index, desc) { + var key = cols[index].key, + sorter = function(a, b) { + a = a.data[key]; + b = b.data[key]; + return a < b ? -1 : a > b ? 1 : 0; + }, + finalSorter = sorter, + tableBody = document.querySelector('.coverage-summary tbody'), + rowNodes = tableBody.querySelectorAll('tr'), + rows = [], + i; + + if (desc) { + finalSorter = function(a, b) { + return -1 * sorter(a, b); + }; + } + + for (i = 0; i < rowNodes.length; i += 1) { + rows.push(rowNodes[i]); + tableBody.removeChild(rowNodes[i]); + } + + rows.sort(finalSorter); + + for (i = 0; i < rows.length; i += 1) { + tableBody.appendChild(rows[i]); + } + } + // removes sort indicators for current column being sorted + function removeSortIndicators() { + var col = getNthColumn(currentSort.index), + cls = col.className; + + cls = cls.replace(/ sorted$/, '').replace(/ sorted-desc$/, ''); + col.className = cls; + } + // adds sort indicators for current column being sorted + function addSortIndicators() { + getNthColumn(currentSort.index).className += currentSort.desc + ? ' sorted-desc' + : ' sorted'; + } + // adds event listeners for all sorter widgets + function enableUI() { + var i, + el, + ithSorter = function ithSorter(i) { + var col = cols[i]; + + return function() { + var desc = col.defaultDescSort; + + if (currentSort.index === i) { + desc = !currentSort.desc; + } + sortByIndex(i, desc); + removeSortIndicators(); + currentSort.index = i; + currentSort.desc = desc; + addSortIndicators(); + }; + }; + for (i = 0; i < cols.length; i += 1) { + if (cols[i].sortable) { + // add the click event handler on the th so users + // dont have to click on those tiny arrows + el = getNthColumn(i).querySelector('.sorter').parentElement; + if (el.addEventListener) { + el.addEventListener('click', ithSorter(i)); + } else { + el.attachEvent('onclick', ithSorter(i)); + } + } + } + } + // adds sorting functionality to the UI + return function() { + if (!getTable()) { + return; + } + cols = loadColumns(); + loadData(); + addSearchBox(); + addSortIndicators(); + enableUI(); + }; +})(); + +window.addEventListener('load', addSorting); diff --git a/packages/horizon-request/coverage/lcov.info b/packages/horizon-request/coverage/lcov.info new file mode 100644 index 00000000..e69de29b diff --git a/packages/horizon-request/examples/cancelRequest/cancelRequestTest.html b/packages/horizon-request/examples/cancelRequest/cancelRequestTest.html new file mode 100644 index 00000000..9f754b86 --- /dev/null +++ b/packages/horizon-request/examples/cancelRequest/cancelRequestTest.html @@ -0,0 +1,58 @@ + + + + + Horizon Request Cancel Request Test + + + +
Horizon Request Cancel Request Test
+
+
+ + +
+
等待发送请求...
+
+ + + + diff --git a/packages/horizon-request/examples/cancelRequest/cancelStyles.css b/packages/horizon-request/examples/cancelRequest/cancelStyles.css new file mode 100644 index 00000000..8437959a --- /dev/null +++ b/packages/horizon-request/examples/cancelRequest/cancelStyles.css @@ -0,0 +1,58 @@ +body { + font-family: Arial, sans-serif; + background-color: #f8f8f8; + padding: 0; + margin: 0; +} + +header { + display: flex; + justify-content: center; + align-items: center; + height: 80px; + background-color: #007bff; + color: #fff; + font-size: 24px; + font-weight: bold; +} + +.container { + max-width: 800px; + margin: 0 auto; + padding: 20px; +} + +.button-group { + display: flex; + justify-content: center; + align-items: center; + margin-top: 30px; +} + +button { + font-family: Arial, sans-serif; + font-size: 16px; + font-weight: bold; + color: #fff; + background-color: #007bff; + border: none; + border-radius: 4px; + padding: 10px 20px; + cursor: pointer; + transition: background-color 0.3s ease-in-out; + margin: 5px; +} + +button:hover { + background-color: #0062cc; +} + +.message { + text-align: center; + font-size: 18px; + margin-top: 30px; + padding: 20px; + border-radius: 4px; + background-color: #fff; + box-shadow: 0 0 5px rgba(0, 0, 0, 0.2); +} diff --git a/packages/horizon-request/examples/interceptor/interceptorStyles.css b/packages/horizon-request/examples/interceptor/interceptorStyles.css new file mode 100644 index 00000000..a0af7a15 --- /dev/null +++ b/packages/horizon-request/examples/interceptor/interceptorStyles.css @@ -0,0 +1,87 @@ +body { + font-family: Arial, sans-serif; + margin: 0; + padding: 2rem; + background-color: #f0f2f5; +} + +header { + display: flex; + justify-content: center; + align-items: center; + height: 80px; + background-color: #007bff; + color: #fff; + font-size: 24px; + font-weight: bold; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +h2 { + color: #333; + border-bottom: 1px solid #ccc; + padding-bottom: 0.5rem; + text-align: center; + margin-bottom: 1.5rem; +} + +h3 { + text-align: center; + font-weight: 600; + margin-bottom: 0.5rem; +} + +.button { + display: flex; + justify-content: center; + align-items: center; + margin-bottom: 20px; +} + +button { + background-color: #007bff; + border: none; + border-radius: 0.25rem; + color: white; + cursor: pointer; + font-size: 1rem; + padding: 0.5rem 1rem; + text-align: center; + text-decoration: none; + display: inline-block; + margin: 0.25rem 1rem; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + transition: all 0.2s ease-in-out; +} + +button:hover { + background-color: #0056b3; + box-shadow: 0 6px 8px rgba(0, 0, 0, 0.1); +} + +pre { + background-color: #f8f8f8; + border: 1px solid #ccc; + padding: 10px; + white-space: pre-wrap; + word-wrap: break-word; + text-align: center; + font-size: large; + border-radius: 4px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + width: 50vh; + display: inline-block; +} + +.response-container { + margin-top: 1rem; + background-color: #fff; + padding: 1.5rem; + border-radius: 4px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + margin-bottom: 2rem; +} + +.pre-container { + text-align: center; +} diff --git a/packages/horizon-request/examples/interceptor/interceptorTest.html b/packages/horizon-request/examples/interceptor/interceptorTest.html new file mode 100644 index 00000000..00452c7b --- /dev/null +++ b/packages/horizon-request/examples/interceptor/interceptorTest.html @@ -0,0 +1,104 @@ + + + + + + Horizon Request Interceptor Test + + + +
Horizon Request interceptor Test
+

使用拦截器:

+
+

响应状态码:

+
+
等待发送请求...
+
+

请求拦截反馈:

+
+
等待发送请求...
+
+

响应拦截反馈:

+
+
等待发送请求...
+
+

响应数据:

+
+
等待发送请求...
+
+
+ +
+
+ +

不使用拦截器:

+
+

响应状态码:

+
+
等待发送请求...
+
+

拦截反馈:

+
+
等待发送请求...
+
+

响应数据:

+
+
等待发送请求...
+
+
+ +
+
+ + + + + + + \ No newline at end of file diff --git a/packages/horizon-request/examples/request/downloadTest.html b/packages/horizon-request/examples/request/downloadTest.html new file mode 100644 index 00000000..7f8d66b2 --- /dev/null +++ b/packages/horizon-request/examples/request/downloadTest.html @@ -0,0 +1,10 @@ + + + + + downloadTest + + +

Hello Horizon Request Master!

+ + \ No newline at end of file diff --git a/packages/horizon-request/examples/request/requestStyles.css b/packages/horizon-request/examples/request/requestStyles.css new file mode 100644 index 00000000..258ea342 --- /dev/null +++ b/packages/horizon-request/examples/request/requestStyles.css @@ -0,0 +1,86 @@ +body { + font-family: Arial, sans-serif; + background-color: #f8f8f8; + margin: 0; + padding: 0; +} + +header { + display: flex; + justify-content: center; + align-items: center; + height: 80px; + background-color: #007bff; + color: #fff; + font-size: 24px; + font-weight: bold; +} + +h1 { + text-align: center; + margin-top: 50px; + color: #2c3e50; + font-size: 36px; +} + +.container { + display: flex; + justify-content: center; + align-items: center; + flex-wrap: wrap; + margin-bottom: 50px; +} + +.card { + background-color: #fff; + border-radius: 5px; + box-shadow: 0 0 5px #aaa; + width: 320px; + padding: 20px; + margin: 20px; + transition: all 0.3s ease; +} + +.card:hover { + box-shadow: 0 0 15px #aaa; + transform: translateY(-5px); +} + +.card h2 { + margin-bottom: 10px; + font-size: 24px; + color: #2c3e50; +} + +.card pre { + background-color: #f0f0f0; + padding: 10px; + font-size: 14px; + border-radius: 5px; + white-space: pre-wrap; + word-wrap: break-word; +} + +.button { + display: flex; + justify-content: center; + align-items: center; + margin-bottom: 20px; +} + +button { + font-family: Arial, sans-serif; + font-size: 16px; + font-weight: bold; + color: #fff; + background-color: #007bff; + border: none; + border-radius: 4px; + padding: 10px 20px; + cursor: pointer; + transition: background-color 0.3s ease-in-out; +} + +button:hover { + background-color: #0056b3; +} diff --git a/packages/horizon-request/examples/request/requestTest.html b/packages/horizon-request/examples/request/requestTest.html new file mode 100644 index 00000000..00bfb70b --- /dev/null +++ b/packages/horizon-request/examples/request/requestTest.html @@ -0,0 +1,214 @@ + + + + + Horizon Request API Test + + + +
Horizon Request API Test
+
+
+

Request

+
等待发送请求...
+
+
+
+
+

GET Request

+
等待发送请求...
+
+
+

POST Request

+
等待发送请求...
+
+
+

PUT Request

+
等待发送请求...
+
+
+

DELETE Request

+
等待发送请求...
+
+
+
+
+

HEAD Request

+
等待发送请求...
+
+
+

OPTIONS Request

+
等待发送请求...
+
+
+

PATCH Request

+
等待发送请求...
+
+
+
+
+

UPLOAD Request

+ +
UploadProgress: 0%
+
等待发送请求...
+
+
+

DOWNLOAD Request

+
+
DownloadProgress: 0%
+
等待发送请求...
+
+
+
+ +
+
+
+ +
+ + + + diff --git a/packages/horizon-request/examples/server/serverTest.mjs b/packages/horizon-request/examples/server/serverTest.mjs new file mode 100644 index 00000000..b09bd2ac --- /dev/null +++ b/packages/horizon-request/examples/server/serverTest.mjs @@ -0,0 +1,93 @@ +import express from "express"; +import * as fs from "fs"; +import bodyParser from "body-parser"; +import cors from "cors"; + +const app = express(); +const port = 3001; + +app.use(bodyParser.json()); +app.use(bodyParser.urlencoded({ extended: true })); + +// 自定义 CORS 配置 +const corsOptions = { + origin: '*', + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'], + allowedHeaders: ['Origin', 'X-Requested-With', 'Content-Type', 'Accept', 'X-Requested-With,content-type', 'HR-Custom-Header'], + exposedHeaders: ['X-Powered-By'], + optionsSuccessStatus: 200, // 设置 OPTIONS 请求成功时的状态码为 200 + credentials: true +}; + +app.use(cors(corsOptions)); + +// 处理 GET 请求 +app.get('/', (req, res) => { + res.send('Hello Horizon Request!'); +}) + +app.get('/data', (req, res) => { + const data = { + message: 'Hello Horizon Request!', + }; + + res.setHeader('Content-Type', 'application/json'); + + res.send(JSON.stringify(data)); +}); + +app.get('/download', (req, res) => { + const filePath = 'D:\\code\\MRcode\\Horizon-Request\\examples\\request\\downloadTest.html'; + const fileName = 'downloadTest.html'; + const fileSize = fs.statSync(filePath).size; + + if (fs.existsSync(filePath)) { + res.setHeader('Content-Disposition', `attachment; filename=${fileName}`); + res.setHeader('Content-Type', 'application/octet-stream'); + res.setHeader('Content-Length', fileSize); + const fileStream = fs.createReadStream(filePath); + fileStream.pipe(res); + } else { + res.status(404).send('File not found'); + } +}); + +// 处理 POST 请求 +app.post('/', (req, res) => { + res.send('Got a POST request!'); +}); + +// 处理 PUT 请求 +app.put('/users', (req, res) => { + res.send('Got a PUT request at /users'); +}); + +// 处理 DELETE 请求 +app.delete('/users', (req, res) => { + res.send('Got a DELETE request at /users'); +}); + +// 处理 HEAD 请求 +app.head('/', (req, res) => { + res.setHeader('x-powered-by', 'Express'); + res.json({ 'x-powered-by': 'Express' }); + res.sendStatus(200); +}); + +// 处理 OPTIONS 请求 +app.options('/', (req, res) => { + res.sendStatus(200); +}); + +// 处理 PATCH 请求 +app.patch('/', (req, res) => { + const name = req.body.name; + const message = `Hello, ${name}! Your name has been updated.`; + res.setHeader('Content-Type', 'text/plain'); + res.send(message); +}); + +// 启动服务器 +app.listen(port, () => { + console.log(`Server listening on port ${port}`); +}); diff --git a/packages/horizon-request/examples/useHR/App .jsx b/packages/horizon-request/examples/useHR/App .jsx new file mode 100644 index 00000000..60d648aa --- /dev/null +++ b/packages/horizon-request/examples/useHR/App .jsx @@ -0,0 +1,36 @@ +import Horizon, { useState } from '@cloudsop/horizon'; +import { useHR } from '../../index'; + +const App = () => { + + const [message, setMessage] = useState('等待发送请求...'); + const [isSent, setSend] = useState(false); + const options = { + pollingInterval: 3000, + enablePollingOptimization: true, + limitation: {minInterval: 500, maxInterval: 4000} + }; + const {data} = useHR('http://localhost:3001/', null, options); + + const handleClick = () => { + setSend(true); + } + + return ( + <> +
useHR Test
+
+
+

{options ? `实时数据流已激活\n更新间隔:${options?.pollingInterval} ms` + : '实时数据流未激活'}

+
{isSent ? data : message}
+
+
+
+ +
+ + ); +} + +export default App; diff --git a/packages/horizon-request/examples/useHR/index.html b/packages/horizon-request/examples/useHR/index.html new file mode 100644 index 00000000..241d7ea7 --- /dev/null +++ b/packages/horizon-request/examples/useHR/index.html @@ -0,0 +1,98 @@ + + + + + useHR Test + + + +
+ + diff --git a/packages/horizon-request/examples/useHR/index.jsx b/packages/horizon-request/examples/useHR/index.jsx new file mode 100644 index 00000000..8959555a --- /dev/null +++ b/packages/horizon-request/examples/useHR/index.jsx @@ -0,0 +1,9 @@ +import Horizon from '@cloudsop/horizon'; +import App from "./App "; + +Horizon.render( + + + , + document.querySelector('#root') +) diff --git a/packages/horizon-request/index.ts b/packages/horizon-request/index.ts new file mode 100644 index 00000000..af1182db --- /dev/null +++ b/packages/horizon-request/index.ts @@ -0,0 +1,57 @@ +import horizonRequest from './src/horizonRequest'; +import useHR from './src/core/useHR/useHR'; + +const { + create, + request, + get, + post, + put, + ['delete']: propToDelete, + head, + options, + HorizonRequest, + HrError, + CanceledError, + isCancel, + CancelToken, + all, + Cancel, + isHrError, + spread, + HrHeaders, + // 兼容axios + Axios, + AxiosError, + AxiosHeaders, + isAxiosError, +} = horizonRequest; + +export { + create, + request, + get, + post, + put, + propToDelete as delete, + head, + options, + HorizonRequest, + HrError, + CanceledError, + isCancel, + CancelToken, + all, + Cancel, + isHrError, + spread, + HrHeaders, + useHR, + // 兼容axios + Axios, + AxiosError, + AxiosHeaders, + isAxiosError, +}; + +export default horizonRequest; diff --git a/packages/horizon-request/jest.config.cjs b/packages/horizon-request/jest.config.cjs new file mode 100644 index 00000000..f58df5e7 --- /dev/null +++ b/packages/horizon-request/jest.config.cjs @@ -0,0 +1,25 @@ +module.exports = { + clearMocks: true, + collectCoverage: true, + coverageDirectory: "coverage", + coverageProvider: "v8", + moduleFileExtensions: [ + "js", + "mjs", + "cjs", + "jsx", + "ts", + "tsx", + "json", + "node" + ], + testEnvironment: "jest-environment-jsdom", + testMatch: ["**/tests/**/*.test.[jt]s?(x)"], + testPathIgnorePatterns: [ + "\\\\node_modules\\\\" + ], + transform: { + "^.+\\.(js|jsx)$": "babel-jest", + "^.+\\.(ts|tsx)$": "babel-jest" + }, +}; diff --git a/packages/horizon-request/nodemon.json b/packages/horizon-request/nodemon.json new file mode 100644 index 00000000..7520e744 --- /dev/null +++ b/packages/horizon-request/nodemon.json @@ -0,0 +1,11 @@ +{ + "restartable": "rs", + "ignore": [ + ".git", + "node_modules/**/node_modules" + ], + "env": { + "NODE_ENV": "development" + }, + "ext": "js,json" +} diff --git a/packages/horizon-request/package.json b/packages/horizon-request/package.json new file mode 100644 index 00000000..978172b8 --- /dev/null +++ b/packages/horizon-request/package.json @@ -0,0 +1,69 @@ +{ + "name": "@cloudsop/horizon-request", + "version": "1.0.20", + "description": "Horizon-request brings you a convenient request experience!", + "main": "index.ts", + "scripts": { + "test": "jest --config jest.config.cjs", + "buildExample": "rollup -c rollup.config.example.js --bundleConfigAsCjs", + "build": "rollup -c rollup.config.js --bundleConfigAsCjs", + "useHRExample": "webpack serve --config webpack.useHR.config.js --mode development", + "server": "nodemon .\\examples\\server\\serverTest.mjs" + }, + "files": [ + "dist", + "README.md" + ], + "repository": { + "type": "git", + "url": "ssh://git@szv-open.codehub.huawei.com:2222/innersource/fenghuang/horizon/horizon-ecosystem.git" + }, + "keywords": [ + "horizon-request" + ], + "author": "", + "license": "ISC", + "devDependencies": { + "@babel/core": "^7.21.8", + "@babel/preset-env": "^7.21.5", + "@babel/preset-react": "^7.9.4", + "@babel/preset-typescript": "^7.21.4", + "@rollup/plugin-commonjs": "^19.0.0", + "@types/jest": "^29.2.5", + "@types/react": "^17.0.34", + "@typescript-eslint/eslint-plugin": "^5.48.1", + "@typescript-eslint/parser": "^5.48.1", + "babel-jest": "^20.0.3", + "babel-loader": "^9.1.0", + "body-parser": "^1.20.2", + "core-js": "3.32.1", + "cors": "^2.8.5", + "eslint": "^8.31.0", + "express": "^4.18.2", + "html-webpack-plugin": "^5.5.3", + "jest": "^29.3.1", + "jest-environment-jsdom": "^29.4.1", + "jsdom": "^22.0.0", + "nodemon": "^2.0.22", + "prettier": "^2.6.2", + "rollup": "^3.20.2", + "rollup-plugin-commonjs": "^10.1.0", + "rollup-plugin-node-resolve": "^5.2.0", + "rollup-plugin-terser": "^7.0.2", + "rollup-plugin-typescript2": "^0.34.1", + "ts-jest": "^29.0.4", + "ts-loader": "^9.4.2", + "ts-node": "^10.9.1", + "tslib": "^2.5.0", + "typescript": "^4.9.4", + "webpack": "^5.81.0", + "webpack-cli": "^5.0.2", + "webpack-dev-server": "^4.13.3" + }, + "dependencies": { + "@cloudsop/horizon": "0.0.50" + }, + "exclude": [ + "node_modules" + ] +} diff --git a/packages/horizon-request/rollup.config.example.js b/packages/horizon-request/rollup.config.example.js new file mode 100644 index 00000000..66a07efd --- /dev/null +++ b/packages/horizon-request/rollup.config.example.js @@ -0,0 +1,20 @@ +import typescript from 'rollup-plugin-typescript2'; +import resolve from 'rollup-plugin-node-resolve'; // 解析第三方模块,并将它们包含在最终的打包文件中 +import commonjs from 'rollup-plugin-commonjs'; // 将 CommonJS 模块转换为 ES6 模块 + +export default { + input: './src/horizonRequest.ts', + output: { + file: 'dist/bundle.js', + format: 'umd', + name: 'horizonRequest', + }, + plugins: [ + typescript({ + tsconfig: 'tsconfig.json', + include: ['src/**/*.ts'], + }), + resolve(), + commonjs(), + ], +}; diff --git a/packages/horizon-request/rollup.config.js b/packages/horizon-request/rollup.config.js new file mode 100644 index 00000000..3dce9328 --- /dev/null +++ b/packages/horizon-request/rollup.config.js @@ -0,0 +1,29 @@ +import typescript from 'rollup-plugin-typescript2'; +import resolve from 'rollup-plugin-node-resolve'; // 解析第三方模块,并将它们包含在最终的打包文件中 +import commonjs from 'rollup-plugin-commonjs'; // 将 CommonJS 模块转换为 ES6 模块 +import { terser } from 'rollup-plugin-terser'; +import { babel } from '@rollup/plugin-babel'; + +export default { + input: './index.ts', + output: { + file: 'dist/horizonRequest.js', + format: 'umd', + exports: 'named', + name: 'horizonRequest', + sourcemap: false, + }, + plugins: [ + resolve(), + commonjs(), + typescript({ + tsconfig: 'tsconfig.json', + include: ['./**/*.ts'], + }), + terser(), + babel({ + babelHelpers: 'bundled', + presets: ['@babel/preset-env'] + }) + ], +}; diff --git a/packages/horizon-request/src/cancel/Cancel.ts b/packages/horizon-request/src/cancel/Cancel.ts new file mode 100644 index 00000000..80d1f6fc --- /dev/null +++ b/packages/horizon-request/src/cancel/Cancel.ts @@ -0,0 +1,11 @@ +class Cancel { + message?: string; + cancelFlag?: boolean; // 用于标志是否为用户主动取消 + + constructor(message?: string, cancelFlag?: boolean) { + this.message = message; + this.cancelFlag = cancelFlag; + } +} + +export default Cancel; diff --git a/packages/horizon-request/src/cancel/CancelError.ts b/packages/horizon-request/src/cancel/CancelError.ts new file mode 100644 index 00000000..ff5d5e0d --- /dev/null +++ b/packages/horizon-request/src/cancel/CancelError.ts @@ -0,0 +1,12 @@ +import HrError from '../core/HrError'; +import { HrRequestConfig } from '../types/interfaces'; + +class CancelError extends HrError { + constructor(message: string | undefined, config: HrRequestConfig, request?: any) { + const errorMessage = message || 'canceled'; + super(errorMessage, (HrError as any).ERR_CANCELED, config, request); + this.name = 'CanceledError'; + } +} + +export default CancelError; diff --git a/packages/horizon-request/src/cancel/CancelToken.ts b/packages/horizon-request/src/cancel/CancelToken.ts new file mode 100644 index 00000000..0da15502 --- /dev/null +++ b/packages/horizon-request/src/cancel/CancelToken.ts @@ -0,0 +1,48 @@ +import Cancel from './Cancel'; +import { CancelFunction, CancelExecutor } from '../types/types'; +import { CancelToken as CancelTokenInstance } from '../types/interfaces'; + +class CancelToken implements CancelTokenInstance { + // 表示取消操作的状态,当取消操作被触发时,Promise将被解析为一个Cancel对象 + promise: Promise; + reason?: Cancel; + // 取消函数,用于触发取消操作 + _cancel?: CancelFunction; + + constructor(executor: CancelExecutor) { + // promise对象在取消操作触发时将被解析 + this.promise = new Promise(resolve => { + this._cancel = (message?: string) => { + if (this.reason) { + return; + } + + this.reason = new Cancel(message, true); + resolve(this.reason); + }; + }); + + executor(this._cancel!); + } + + throwIfRequested() { + if (this.reason) { + throw this.reason; + } + } + + // 创建一个 CancelToken 实例和关联的取消函数 + static source() { + let cancel!: CancelFunction; + const token = new CancelToken(cancelFunc => { + cancel = cancelFunc; + }); + + return { + token, + cancel, + }; + } +} + +export default CancelToken; diff --git a/packages/horizon-request/src/cancel/checkCancel.ts b/packages/horizon-request/src/cancel/checkCancel.ts new file mode 100644 index 00000000..3651b334 --- /dev/null +++ b/packages/horizon-request/src/cancel/checkCancel.ts @@ -0,0 +1,6 @@ +// 检查是否为用户主动请求取消场景 +function checkCancel(input: any): boolean { + return input.cancelFlag || false; +} + +export default checkCancel; diff --git a/packages/horizon-request/src/config/defaultConfig.ts b/packages/horizon-request/src/config/defaultConfig.ts new file mode 100644 index 00000000..5293ab00 --- /dev/null +++ b/packages/horizon-request/src/config/defaultConfig.ts @@ -0,0 +1,28 @@ +const defaultConfig = { + method: 'GET', + + transitional: { + // 解析 JSON 时静默处理错误。在启用时,如果发生 JSON 解析错误,将不会抛出异常,而是返回解析前的原始数据 + silentJSONParsing: true, + // 控制是否强制解析 JSON 数据。在启用时,无论数据的 MIME 类型(如text/plain)是什么,都会尝试将其解析为 JSON + forcedJSONParsing: true, + // 控制是否在超时错误中提供更明确的错误信息。在启用时,将提供更具体的错误消息,指示发生的超时错误类型 + clarifyTimeoutError: false, + }, + + timeout: 0, + + maxBodyLength: -1, + + validateStatus: (status: number) => { + return status >= 200 && status < 300; + }, + + headers: { + common: { + Accept: 'application/json, text/plain, */*', + }, + }, +}; + +export default defaultConfig; diff --git a/packages/horizon-request/src/core/HorizonRequest.ts b/packages/horizon-request/src/core/HorizonRequest.ts new file mode 100644 index 00000000..dd7a0baa --- /dev/null +++ b/packages/horizon-request/src/core/HorizonRequest.ts @@ -0,0 +1,211 @@ +import getMergedConfig from '../utils/configUtils/getMergedConfig'; +import HrHeaders from './HrHeaders'; +import InterceptorManager from '../interceptor/InterceptorManager'; +import processRequest from '../request/processRequest'; +import getRequestInterceptorsInfo from '../interceptor/getRequestInterceptorsInfo'; +import getResponseInterceptorChain from '../interceptor/getResponseInterceptorChain'; +import handleAsyncInterceptor from '../interceptor/handleAsyncInterceptor'; +import handleSyncInterceptor from '../interceptor/handleSyncInterceptor'; +import defaultConfig from '../config/defaultConfig'; +import { Method } from '../types/types'; +import { + HrRequestConfig, + HrResponse, + HrInterface, + HrInstance, + Interceptors, +} from '../types/interfaces'; + +class HorizonRequest implements HrInterface { + defaultConfig: HrRequestConfig; + interceptors: Interceptors; + processRequest: (config: HrRequestConfig) => Promise; + + constructor(config: HrRequestConfig) { + this.defaultConfig = config; + // 初始化拦截器 + this.interceptors = { + request: new InterceptorManager(), + response: new InterceptorManager(), + }; + this.processRequest = processRequest; + } + + request(requestParam: string | Record, config?: HrRequestConfig): Promise> { + // 1. 解析参数 + const mergedConfig = this.preprocessing(requestParam, config); + + // 2. 生成请求拦截器 + let isSync: boolean | undefined = true; + + // 生成请求和响应的拦截器链 + const requestInterceptorsInfo = getRequestInterceptorsInfo(this.interceptors, config, isSync); + const requestInterceptorChain = requestInterceptorsInfo.requestInterceptorChain; + isSync = requestInterceptorsInfo.isSync; + const responseInterceptorChain = getResponseInterceptorChain.call(this); + + // 存在异步拦截器 + if (!isSync) { + return handleAsyncInterceptor( + this.processRequest, + requestInterceptorChain, + responseInterceptorChain, + mergedConfig + ); + } + + // 全都是同步拦截器处理 + return handleSyncInterceptor( + this.processRequest, + mergedConfig, + requestInterceptorChain, + responseInterceptorChain + ); + } + + private preprocessing(requestParam: string | Record, config?: HrRequestConfig) { + let configOperation: Record = {}; + + if (typeof requestParam === 'object') { + configOperation = { ...requestParam }; + } else { + configOperation.url = requestParam; + configOperation = { ...configOperation, ...config }; + } + + const mergedConfig: HrRequestConfig = getMergedConfig(this.defaultConfig, configOperation); + mergedConfig.method = (mergedConfig.method || this.defaultConfig.method || 'GET').toUpperCase() as Method; + + const { headers } = mergedConfig; + if (headers) { + const contextHeaders = headers[mergedConfig.method] + ? { ...headers.common, ...headers[mergedConfig.method] } + : headers.common; + + // 删除 headers 中预定义的 common 请求头,确保 headers 对象只包含自定义的请求头 + if (contextHeaders) { + Object.keys(headers).forEach(key => { + if (key === 'common') { + delete headers[key]; + } + }); + } + + mergedConfig.headers = HrHeaders.concat(contextHeaders, headers); + } + + return mergedConfig; + } + + get(url: string, config: HrRequestConfig) { + return this.request( + getMergedConfig(config || {}, { + method: 'get', + url, + data: (config || {}).data, + }) + ); + } + + delete(url: string, config: HrRequestConfig) { + return this.request( + getMergedConfig(config || {}, { + method: 'delete', + url, + data: (config || {}).data, + }) + ); + } + + head(url: string, config: HrRequestConfig) { + return this.request( + getMergedConfig(config || {}, { + method: 'head', + url, + data: (config || {}).data, + }) + ); + } + + options(url: string, config: HrRequestConfig) { + return this.request( + getMergedConfig(config || {}, { + method: 'options', + url, + data: (config || {}).data, + }) + ); + } + + post(url: string, data: any, config: HrRequestConfig) { + return this.request( + getMergedConfig(config || {}, { + method: 'post', + url, + data, + }) + ); + } + + postForm(url: string, data: any, config: HrRequestConfig) { + return this.request( + getMergedConfig(config || {}, { + method: 'post', + headers: { 'Content-Type': 'multipart/form-data' }, + url, + data, + }) + ); + } + + put(url: string, data: any, config: HrRequestConfig) { + return this.request( + getMergedConfig(config || {}, { + method: 'put', + url, + data, + }) + ); + } + + putForm(url: string, data: any, config: HrRequestConfig) { + return this.request( + getMergedConfig(config || {}, { + method: 'put', + headers: { 'Content-Type': 'multipart/form-data' }, + url, + data, + }) + ); + } + + patch(url: string, data: any, config: HrRequestConfig) { + return this.request( + getMergedConfig(config || {}, { + method: 'patch', + url, + data, + }) + ); + } + + patchForm(url: string, data: any, config: HrRequestConfig) { + return this.request( + getMergedConfig(config || {}, { + method: 'patch', + headers: { 'Content-Type': 'multipart/form-data' }, + url, + data, + }) + ); + } + + // 创建 Hr 实例 + static create(instanceConfig?: HrRequestConfig): HrInstance { + const config = getMergedConfig(defaultConfig, instanceConfig || {}); + + return new HorizonRequest(config) as unknown as HrInstance; + } +} + +export default HorizonRequest; diff --git a/packages/horizon-request/src/core/HrError.ts b/packages/horizon-request/src/core/HrError.ts new file mode 100644 index 00000000..9fa3192c --- /dev/null +++ b/packages/horizon-request/src/core/HrError.ts @@ -0,0 +1,102 @@ +import utils from '../utils/commonUtils/utils'; +import { HrErrorInterface, HrInstance, HrRequestConfig, HrResponse } from '../types/interfaces'; + +class HrError extends Error implements HrErrorInterface { + code?: string; + config?: HrRequestConfig; + request?: HrInstance; + response?: HrResponse; + + constructor(message: string, code?: string, config?: HrRequestConfig, request?: any, response?: HrResponse) { + super(message); + + this.message = message; + this.name = 'HrError'; + this.code = code; + this.config = config; + this.request = request; + this.response = response; + } + + toJSON() { + return { + message: this.message, + name: this.name, + config: utils.toJSONSafe(this.config as Record), + code: this.code, + status: this.response && this.response.status ? this.response.status : null, + }; + } + + // 从现有的 Error 对象创建一个新的 HrError 对象 + static from( + error: Error, + code: string, + config: HrRequestConfig, + request: any, + response: HrResponse, + customProps?: Record + ): HrError { + // 由传入的 Error 对象属性初始化 HrError 实例化对象 + const hrError = new HrError(error.message, code, config, request, response); + + // 将现有的 Error 对象的属性复制到新创建的对象中 + utils.flattenObject( + error, + hrError, + obj => obj !== Error.prototype, + prop => prop !== 'isHrError' + ); + + // 设置基本错误类型属性 + hrError.name = error.name; + + if (customProps) { + Object.assign(hrError, customProps); + } + + return hrError; + } +} + +// 在 HrError 类的原型链中添加 Error 类的原型,使 HrError 成为 Error 的子类 +Object.setPrototypeOf(HrError.prototype, Error.prototype); +Object.defineProperties(HrError.prototype, { + toJSON: { + value: HrError.prototype.toJSON, + }, + isHrError: { + value: true, + }, +}); + +const errorTypes = [ + 'ERR_BAD_OPTION_VALUE', + 'ERR_BAD_OPTION', + 'ECONNABORTED', + 'ETIMEDOUT', + 'ERR_NETWORK', + 'ERR_FR_TOO_MANY_REDIRECTS', + 'ERR_DEPRECATED', + 'ERR_BAD_RESPONSE', + 'ERR_BAD_REQUEST', + 'ERR_CANCELED', + 'ERR_NOT_SUPPORT', + 'ERR_INVALID_URL', +]; + +const descriptors: PropertyDescriptorMap = errorTypes.reduce((acc, code) => { + acc[code] = { value: code }; + return acc; +}, {}); + +// 将 descriptors 对象中定义的属性添加到 HrError 类上 +Object.defineProperties(HrError, descriptors); + +// 在 HrError 类的原型上定义了一个名为 isHrError 的属性,用于判断错误对象是否为 HrError +Object.defineProperty(HrError.prototype, 'isHrError', { value: true }); + +// 判断输入值是否为 HrError +export const isHrError = (value: any) => !!value.isHrError; + +export default HrError; diff --git a/packages/horizon-request/src/core/HrHeaders.ts b/packages/horizon-request/src/core/HrHeaders.ts new file mode 100644 index 00000000..47bdadc2 --- /dev/null +++ b/packages/horizon-request/src/core/HrHeaders.ts @@ -0,0 +1,227 @@ +import utils from '../utils/commonUtils/utils'; +import convertRawHeaders from '../utils/headerUtils/convertRawHeaders'; +import { HeaderMatcher } from '../types/types'; +import checkHeaderName from '../utils/headerUtils/checkHeaderName'; +import processValueByParser from '../utils/headerUtils/processValueByParser'; +import deleteHeader from '../utils/headerUtils/deleteHeader'; + +class HrHeaders { + // 定义 HrHeaders 类索引签名 + [key: string]: any; + + constructor(headers?: Record | HrHeaders) { + // 将默认响应头加入 HrHeaders + this.defineAccessor(); + + if (headers) { + this.set(headers); + } + } + + private _setHeader( + header: Record | HrHeaders | string, + _value: string | string[], + _header: string + ) { + const normalizedHeader = String(header).trim().toLowerCase(); + const key = utils.getObjectKey(this, normalizedHeader); + + // this[key] 可能为 false + if (!key || this[key] === undefined) { + this[key || _header] = utils.getNormalizedValue(_value); + } + }; + + private _setHeaders(headers: Record | HrHeaders | string) { + return utils.forEach(headers, (_value: string | string[], _header: string) => { + return this._setHeader(headers, _value, _header); + }); + } + + set(header: Record | HrHeaders | string): this { + // 通过传入的 headers 创建 HrHeaders 对象 + if (utils.checkPlainObject(header) || header instanceof this.constructor) { + this._setHeaders(header); + } else if (utils.checkString(header) && (header = header.trim()) && !checkHeaderName(header as string)) { + this._setHeaders(convertRawHeaders(header as string)); + } else { + if (header) { + this._setHeader(header, header as string, header as string); + } + } + + return this; + } + + // 从对象中获取指定 header 的值,并根据可选的parser参数来处理和返回这个值 + get(header: string, parser?: HeaderMatcher): string | string[] | null | undefined { + const normalizedHeader = String(header).trim().toLowerCase(); + + if (!normalizedHeader) { + return; + } + + const key = utils.getObjectKey(this, normalizedHeader); + + if (!key) { + return; + } + + const value = (this as any)[key]; + + return processValueByParser(key, value, parser); + } + + has(header: string): boolean { + const normalizedHeader = String(header).trim().toLowerCase(); + + if (normalizedHeader) { + const key = utils.getObjectKey(this, normalizedHeader); + return !!(key && this[key] !== undefined); + } + + return false; + } + + delete(header: string | string[]): boolean { + if (Array.isArray(header)) { + return header.some(deleteHeader, this); + } else { + return deleteHeader.call(this, header); + } + } + + clear(): boolean { + const keys = Object.keys(this); + let deleted = false; + + for (const key of keys) { + delete this[key]; + deleted = true; + } + + return deleted; + } + + concat(...items: (Record | HrHeaders)[]): HrHeaders { + return HrHeaders.concat(this, ...items); + } + + toJSON(arrayToStr?: boolean): Record { + // 过滤无意义的转换 + const entries = Object.entries(this).filter(([_, value]) => { + return value != null && value !== false; + }); + + const mappedEntries = entries.map(([header, value]) => { + // 配置 arrayToStr 将 value 对应的数组值转换成逗号分隔,如 "hobbies": ["reading", "swimming", "hiking"] -> "hobbies": "reading, swimming, hiking" + return [header, arrayToStr && Array.isArray(value) ? value.join(', ') : value]; + }); + + return Object.fromEntries(mappedEntries); + } + + toString(): string { + const entries = this.toJSON(); + return Object.keys(entries).reduce((acc, header) => { + return acc + header + ': ' + entries[header] + '\n'; + }, ''); + } + + normalize(): this { + // 存储已处理过的 header + const headers: Record = {}; + + for (const header in this) { + if (Object.prototype.hasOwnProperty.call(this, header)) { + const value = this[header]; + const key = utils.getObjectKey(headers, header); + + // 若 key 存在,说明当前遍历到的 header 已经被处理过 + if (key) { + this[key] = utils.getNormalizedValue(value); + + // header 和 key 不相等,key 是忽略大小写的,所以需要删除处理前的 header + delete this[header]; + continue; + } + + const normalizedHeader = header.trim(); + + if (normalizedHeader !== header) { + delete this[header]; + } + + this[normalizedHeader] = utils.getNormalizedValue(value); + + headers[normalizedHeader] = true; + } + } + + return this; + } + + defineAccessor() { + // 用于标记当前响应头访问器是否已添加 + const accessors = {}; + + // 定义默认头部 + const defaultHeaders = ['Content-Type', 'Content-Length', 'Accept', 'Accept-Encoding', 'User-Agent']; + + // 将默认响应头加入 HrHeaders + defaultHeaders.forEach(header => { + if (!accessors[header]) { + Object.defineProperty(this, header, { + writable: true, + enumerable: true, + configurable: true, + }); + + accessors[header] = true; + } + }); + } + + static from(thing: Record | HrHeaders): HrHeaders { + if (thing instanceof HrHeaders) { + return thing; + } else { + const newInstance = new HrHeaders(thing); + + // 删除值为 undefined 请求头, fetch 进行自动配置 + for (const key in newInstance) { + if (newInstance[key] === undefined) { + delete newInstance[key]; + } + } + + return newInstance; + } + } + + static concat( + firstItem: Record | HrHeaders, + ...otherItems: (Record | HrHeaders)[] + ): HrHeaders { + // 初始化一个 HrHeaders 对象实例 + const newInstance = new HrHeaders(firstItem); + const mergedObject = Object.assign({}, newInstance, ...otherItems); + + for (const key in mergedObject) { + if (Object.prototype.hasOwnProperty.call(mergedObject, key)) { + newInstance[key] = mergedObject[key]; + } + } + + // 删除值为 undefined 请求头, fetch 进行自动配置 + for (const key in newInstance) { + if (newInstance[key] === undefined) { + delete newInstance[key]; + } + } + + return newInstance; + } +} + +export default HrHeaders; diff --git a/packages/horizon-request/src/core/useHR/HRClient.ts b/packages/horizon-request/src/core/useHR/HRClient.ts new file mode 100644 index 00000000..4a73b764 --- /dev/null +++ b/packages/horizon-request/src/core/useHR/HRClient.ts @@ -0,0 +1,146 @@ +import horizonRequest from '../../horizonRequest'; +import { CacheItem, HrRequestConfig, Limitation, QueryOptions } from '../../types/interfaces'; +import utils from "../../utils/commonUtils/utils"; + +// 兼容 IE 上没有 CustomEvent 对象 +function createCustomEvent(eventName: string, options?: Record) { + options = options || { bubbles: false, cancelable: false, detail: null }; + const event = document.createEvent('CustomEvent'); + event.initCustomEvent(eventName, options.bubbles, options.cancelable, options.detail); + return event; +} + +class HRClient { + private cache: Map = new Map(); + private historyData: string[] = []; + public requestEvent = utils.isIE() ? createCustomEvent('request') : new CustomEvent('request'); + + public async query(url: string, config?: HrRequestConfig, options: QueryOptions = {}): Promise { + const { + pollingInterval, + enablePollingOptimization, + limitation, + capacity = 100, + windowSize = 5, + } = options; + let cacheItem = this.cache.get(url); + + if (cacheItem && pollingInterval && Date.now() - cacheItem.lastUpdated < pollingInterval) { + return cacheItem.data; // 返回缓存中的数据 + } + + const response = await horizonRequest.get(url, config); + const data = response.data; + + // 如果轮询已配置,设置一个定时器 + if (pollingInterval) { + if (cacheItem && cacheItem.timeoutId) { + clearTimeout(cacheItem.timeoutId); // 清除已存在的定时器 + } + + let optimizedInterval; + + // 如果启用动态缓存策略 + if (enablePollingOptimization) { + optimizedInterval = this.getDynamicInterval(pollingInterval, windowSize, limitation); + } + + const timeoutId = setInterval(async () => { + const result = await this.query(url, config, options); // 执行轮询查询 + document.dispatchEvent(new CustomEvent('request', { detail: result })); + }, optimizedInterval ?? pollingInterval); + + cacheItem = { + data, + lastUpdated: Date.now(), + pollingInterval: optimizedInterval ?? pollingInterval, + timeoutId, + }; + + // 保存历史数据,以便动态缓存策略分析 + this.historyData.push(data as string); + // 历史数据超过配置容量便老化最早的数据 + if (this.historyData.length > capacity) { + this.historyData.unshift(); + } + } else { + cacheItem = { + data, + lastUpdated: Date.now(), + }; + } + + // 更新缓存 + this.cache.set(url, cacheItem); + + return data; + } + + // 计算滑动窗口内相邻字符串的相同个数 + private countAdjacentMatches(data: string[]): number { + let count = 0; + + for (let i = 0; i < data.length - 1; i++) { + if (data[i] === data[i + 1]) { + count++; + } + } + + return count; + } + + // 启用动态缓存策略,pollingInterval 作为初始轮询时间并分析历史请求数据计算最优轮询时间减缓服务器压力 + private getDynamicInterval(pollingInterval: number, windowSize: number, limitation?: Limitation): number { + // 历史数据量过少时,使用用户配置的初始轮询时间 + if (this.historyData.length <= windowSize + 5) { + return pollingInterval; + } + + const minInterval = limitation?.minInterval ?? 100; // 最小时间间隔 + const maxInterval = limitation?.maxInterval ?? 60000; // 最大时间间隔 + + // PID 控制器初始化参数 + const Kp = 0.2; // 比例常数 + const Ki = 0.2; // 积分常数 + const Kd = 0.1; // 微分常数 + const targetCount = windowSize - 1; // 目标结果数量 + let lastError = 0; // 上一个错误变量,初始值设为0 + + // 计算总窗口数量 + const numWindows = this.historyData.length - windowSize + 1; + + // 根据每个滑动窗口内相邻字符串的相同个数来反馈控制调整请求间隔 + for (let i = 0; i < numWindows; i++) { + // 获取当前窗口的数据 + const windowData = this.historyData.slice(i, i + windowSize); + const windowCount = this.countAdjacentMatches(windowData); + + // 根据PID控制器计算调整量 + const error = windowCount - targetCount; + const output = Kp * error + Ki * windowCount + Kd * (error - lastError); + + // 根据调整量更新请求间隔 + pollingInterval += output * 100; + + // 更新上一个错误变量 + lastError = error; + } + + // 限制时间间隔的范围在最小和最大值之间 + pollingInterval = Math.max(pollingInterval, minInterval); + pollingInterval = Math.min(pollingInterval, maxInterval); + + return pollingInterval; + } + + public invalidateCache(url: string): void { + const cacheItem = this.cache.get(url); + if (cacheItem && cacheItem.timeoutId) { + clearTimeout(cacheItem.timeoutId); // 清除定时器 + } + + this.cache.delete(url); // 从缓存中删除条目 + } +} + +export default HRClient; diff --git a/packages/horizon-request/src/core/useHR/useHR.ts b/packages/horizon-request/src/core/useHR/useHR.ts new file mode 100644 index 00000000..2dea251c --- /dev/null +++ b/packages/horizon-request/src/core/useHR/useHR.ts @@ -0,0 +1,43 @@ +import Horizon from '@cloudsop/horizon'; +import HRClient from './HRClient'; +import { HrRequestConfig, QueryOptions } from '../../types/interfaces'; + +// 全局初始化一个 HRClient 实例 +const hrClient = new HRClient(); + +const useHR = (url: string, config?: HrRequestConfig, options?: QueryOptions): { data?: T; error?: any } => { + const [data, setData] = Horizon.useState(null as unknown as T); + const [error, setError] = Horizon.useState(null); + + function handleRequest(result: any) { + return (event: any) => { + result = event.detail; + setData(result); + }; + } + + Horizon.useEffect(() => { + const fetchData = async () => { + try { + let result = await hrClient.query(url, config, options); + document.addEventListener('request', handleRequest(result)); + + setData(result); // 未设置轮询查询时展示一次 + } catch (err) { + setError(err); + } + }; + + fetchData().catch(() => {}); // catch作用是消除提示 + + // 清除缓存 + return () => { + hrClient.invalidateCache(url); + document.removeEventListener('request', handleRequest); + }; + }, [url, config]); + + return { data, error }; +}; + +export default useHR; diff --git a/packages/horizon-request/src/dataTransformers/transformRequest.ts b/packages/horizon-request/src/dataTransformers/transformRequest.ts new file mode 100644 index 00000000..fd6022cf --- /dev/null +++ b/packages/horizon-request/src/dataTransformers/transformRequest.ts @@ -0,0 +1,68 @@ +import utils from '../utils/commonUtils/utils'; +import HrHeaders from '../core/HrHeaders'; +import getJSONByFormData from '../utils/dataUtils/getJSONByFormData'; +import getFormData from '../utils/dataUtils/getFormData'; +import { Strategy } from '../types/types'; + +// 策略映射,用于根据数据类型处理和转换请求数据 +const strategies: Record = { + HTMLForm: data => { + return new FormData(data); + }, + FormData: (data, headers, hasJSONContentType: boolean) => { + return hasJSONContentType ? JSON.stringify(getJSONByFormData(data)) : data; + }, + StreamOrFileOrBlob: (data, headers) => { + return data; + }, + URLSearchParams: (data, headers) => { + headers['Content-Type'] = headers['Content-type'] ?? 'application/x-www-form-urlencoded;charset=utf-8'; + return data.toString(); + }, + MultipartFormData: (data, headers, isFileList: boolean) => { + + return getFormData(isFileList ? { 'files[]': data } : data); + }, + JSONData: (data, headers) => { + headers['Content-Type'] = headers['Content-Type'] ?? 'application/json'; + return utils.stringifySafely(data); + }, +}; + +function transformRequest(data: any, headers: HrHeaders): any { + const contentType = headers['Content-Type'] || ''; + const hasJSONContentType = contentType.indexOf('application/json') > -1; + const isObjectPayload = utils.checkObject(data); + + if (isObjectPayload && utils.checkHTMLForm(data)) { + return strategies.HTMLForm(data, headers); + } + + if (utils.checkFormData(data)) { + return strategies.FormData(data, headers, hasJSONContentType); + } + + if (utils.checkStream(data) || utils.checkFile(data) || utils.checkBlob(data)) { + return strategies.StreamOrFileOrBlob(data, headers); + } + + if (utils.checkURLSearchParams(data)) { + return strategies.URLSearchParams(data, headers); + } + + let isFileList: boolean; + + if (isObjectPayload) { + if ((isFileList = utils.checkFileList(data)) || contentType.indexOf('multipart/form-data') > -1) { + return strategies.MultipartFormData(data, headers, isFileList); + } + } + + if (isObjectPayload || hasJSONContentType) { + return strategies.JSONData(data, headers); + } + + return data; +} + +export default transformRequest; diff --git a/packages/horizon-request/src/dataTransformers/transformResponse.ts b/packages/horizon-request/src/dataTransformers/transformResponse.ts new file mode 100644 index 00000000..8f9d2e57 --- /dev/null +++ b/packages/horizon-request/src/dataTransformers/transformResponse.ts @@ -0,0 +1,34 @@ +import { HrRequestConfig, HrResponse, TransitionalOptions } from '../types/interfaces'; +import HrError from '../core/HrError'; +import defaultConfig from '../config/defaultConfig'; + +// this 需要拿到上下文的config,processRequest 是动态调用的,直接将 config 当参数传入会拿到错误的 config +function transformResponse(this: HrRequestConfig, data: any): T | string | null { + const transitional: TransitionalOptions = this.transitional || defaultConfig.transitional; + + // 判断是否需要强制 JSON 解析 + const enableForcedJSONParsing: boolean | undefined = transitional && transitional.forcedJSONParsing; + const isJSON: boolean = this.responseType === 'json'; + + // 如果数据存在且为字符串类型,并且请求的响应类型为 JSON 则进行强制解析 + if (data && typeof data === 'string' && ((enableForcedJSONParsing && !this.responseType) || isJSON)) { + // 解析 JSON 失败是否抛出异常 + const enableSilentJSONParsing: boolean | undefined = transitional && transitional.silentJSONParsing; + const enableStrictJSONParsing: boolean = !enableSilentJSONParsing && isJSON; + + try { + return JSON.parse(data); + } catch (error) { + if (enableStrictJSONParsing) { + if ((error as Error).name !== 'SyntaxError') { + throw HrError.from(error as Error, (HrError as any).ERR_BAD_RESPONSE, this, null, (this as any).response); // 使用拦截器可能会将 response 写入 config 中 + } + throw error; + } + } + } + + return data; +} + +export default transformResponse; diff --git a/packages/horizon-request/src/horizonRequest.ts b/packages/horizon-request/src/horizonRequest.ts new file mode 100644 index 00000000..57997228 --- /dev/null +++ b/packages/horizon-request/src/horizonRequest.ts @@ -0,0 +1,61 @@ +import HorizonRequest from './core/HorizonRequest'; +import utils from './utils/commonUtils/utils'; +import { CancelTokenStatic, HrInterface, HrRequestConfig } from './types/interfaces'; +import defaultConfig from './config/defaultConfig'; +import fetchLike from './request/ieCompatibility/fetchLike'; +import CancelToken from './cancel/CancelToken'; +import checkCancel from './cancel/checkCancel'; +import HrError, { isHrError } from './core/HrError'; +import buildInstance from './utils/instanceUtils/buildInstance'; +import HrHeaders from './core/HrHeaders'; +import CancelError from './cancel/CancelError'; +import 'core-js/stable'; + +// 使用默认配置创建 hr 对象实例 +const horizonRequest = buildInstance(defaultConfig as HrRequestConfig); + +// 提供 Hr 类继承 +horizonRequest.HorizonRequest = HorizonRequest as unknown as HrInterface; + +// 创建 hr 实例的工厂函数 +horizonRequest.create = HorizonRequest.create; + +// 提供取消请求令牌 +horizonRequest.CancelToken = CancelToken as CancelTokenStatic; + +horizonRequest.isCancel = checkCancel; + +horizonRequest.Cancel = CancelError; + +horizonRequest.all = utils.all; + +horizonRequest.spread = utils.spread; + +horizonRequest.default = horizonRequest; + +horizonRequest.CanceledError = CancelError; + +horizonRequest.HrError = HrError; + +horizonRequest.isHrError = isHrError; + +horizonRequest.HrHeaders = HrHeaders; + +horizonRequest.defaults = defaultConfig as HrRequestConfig; + +/*--------------------------------兼容axios-----------------------------------*/ + +horizonRequest.Axios = HorizonRequest; + +horizonRequest.AxiosError = HrError; + +horizonRequest.isAxiosError = isHrError; + +horizonRequest.AxiosHeaders = HrHeaders; + +export default horizonRequest; + +// 兼容 IE 浏览器 fetch +if (utils.isIE()) { + (window as any).fetch = fetchLike; +} diff --git a/packages/horizon-request/src/interceptor/InterceptorManager.ts b/packages/horizon-request/src/interceptor/InterceptorManager.ts new file mode 100644 index 00000000..d079bd3f --- /dev/null +++ b/packages/horizon-request/src/interceptor/InterceptorManager.ts @@ -0,0 +1,45 @@ +import utils from '../utils/commonUtils/utils'; +import { InterceptorHandler, HrInterceptorManager } from '../types/interfaces'; +import { FulfilledFn, RejectedFn } from '../types/types'; + +class InterceptorManager implements HrInterceptorManager { + private handlers: (InterceptorHandler | null)[]; + + constructor() { + this.handlers = []; + } + + use( + fulfilled?: FulfilledFn, + rejected?: RejectedFn, + options?: { synchronous?: boolean; runWhen?: (value: V) => boolean } + ): number { + this.handlers.push({ + fulfilled, + rejected, + synchronous: options ? options.synchronous : false, + runWhen: options ? options.runWhen : undefined, + }); + return this.handlers.length - 1; + } + + eject(id: number): void { + if (this.handlers[id]) { + this.handlers[id] = null; + } + } + + clear(): void { + this.handlers = []; + } + + forEach(func: Function) { + utils.forEach(this.handlers, function forEachHandler(h: any) { + if (h !== null) { + func(h); + } + }); + } +} + +export default InterceptorManager; \ No newline at end of file diff --git a/packages/horizon-request/src/interceptor/getRequestInterceptorsInfo.ts b/packages/horizon-request/src/interceptor/getRequestInterceptorsInfo.ts new file mode 100644 index 00000000..6f4eec32 --- /dev/null +++ b/packages/horizon-request/src/interceptor/getRequestInterceptorsInfo.ts @@ -0,0 +1,26 @@ +import { HrRequestConfig, InterceptorHandler, Interceptors } from '../types/interfaces'; +import { FulfilledFn } from '../types/types'; + +// 获取请求拦截器链以及是否异步信息 +function getRequestInterceptorsInfo( + interceptors: Interceptors, + config: HrRequestConfig | undefined, + isSync: boolean | undefined +) { + const requestInterceptorChain: (FulfilledFn | undefined)[] = []; + + interceptors.request.forEach((interceptor: InterceptorHandler) => { + if (typeof interceptor.runWhen === 'function' && !interceptor.runWhen(config)) { + return; + } + + // 只要有一个异步拦截器则拦截器链为异步 + isSync = isSync && interceptor.synchronous; + + requestInterceptorChain.unshift(interceptor.fulfilled, interceptor.rejected); + }); + + return { requestInterceptorChain, isSync: isSync }; +} + +export default getRequestInterceptorsInfo; diff --git a/packages/horizon-request/src/interceptor/getResponseInterceptorChain.ts b/packages/horizon-request/src/interceptor/getResponseInterceptorChain.ts new file mode 100644 index 00000000..421259cd --- /dev/null +++ b/packages/horizon-request/src/interceptor/getResponseInterceptorChain.ts @@ -0,0 +1,13 @@ +import { FulfilledFn } from '../types/types'; +import { InterceptorHandler } from '../types/interfaces'; + +function getResponseInterceptorChain(this: any) { + const responseInterceptorChain: (FulfilledFn | undefined)[] = []; + this.interceptors.response.forEach((interceptor: InterceptorHandler) => { + responseInterceptorChain.push(interceptor.fulfilled, interceptor.rejected); + }); + + return responseInterceptorChain; +} + +export default getResponseInterceptorChain; diff --git a/packages/horizon-request/src/interceptor/handleAsyncInterceptor.ts b/packages/horizon-request/src/interceptor/handleAsyncInterceptor.ts new file mode 100644 index 00000000..44c7cafc --- /dev/null +++ b/packages/horizon-request/src/interceptor/handleAsyncInterceptor.ts @@ -0,0 +1,23 @@ +import { FulfilledFn } from '../types/types'; +import { HrRequestConfig } from '../types/interfaces'; + +// 处理含有异步拦截器情况 +function handleAsyncInterceptor( + processFunc: (value: any) => any, + requestInterceptorChain: (FulfilledFn | undefined)[], + responseInterceptorChain: (FulfilledFn | undefined)[], + mergedConfig: HrRequestConfig +): Promise { + // undefined 占位 rejected 回调函数 + const chain = [...requestInterceptorChain, processFunc, undefined, ...responseInterceptorChain]; + let promise = Promise.resolve(mergedConfig); + + for (let i = 0; i < chain.length; i += 2) { + // 将拦截器使用Promise链进行链接 + promise = promise.then(chain[i], chain[i + 1]); + } + + return promise; +} + +export default handleAsyncInterceptor; diff --git a/packages/horizon-request/src/interceptor/handleSyncInterceptor.ts b/packages/horizon-request/src/interceptor/handleSyncInterceptor.ts new file mode 100644 index 00000000..40b676ba --- /dev/null +++ b/packages/horizon-request/src/interceptor/handleSyncInterceptor.ts @@ -0,0 +1,41 @@ +import { FulfilledFn } from '../types/types'; + +// 处理同步拦截器 +function handleSyncInterceptor( + processFunc: (value: any) => any, + mergedConfig: any, + requestInterceptorChain: (FulfilledFn | undefined)[], + responseInterceptorChain: (FulfilledFn | undefined)[] +): Promise { + let newConfig = mergedConfig; + let promise; + + for (let i = 0; i < requestInterceptorChain.length; i += 2) { + const fulfilled = requestInterceptorChain[i]; + const rejected = requestInterceptorChain[i + 1]; + + // 返回拦截器处理后的 newConfig + try { + newConfig = fulfilled ? fulfilled(newConfig) : newConfig; + } catch (error) { + if (rejected) { + rejected(error); + } + break; + } + } + + try { + promise = processFunc(newConfig); + } catch (error) { + return Promise.reject(error); + } + + for (let i = 0; i < responseInterceptorChain.length; i += 2) { + promise = promise.then(responseInterceptorChain[i], responseInterceptorChain[i + 1]); + } + + return promise; +} + +export default handleSyncInterceptor; diff --git a/packages/horizon-request/src/request/fetchRequest.ts b/packages/horizon-request/src/request/fetchRequest.ts new file mode 100644 index 00000000..99acfec3 --- /dev/null +++ b/packages/horizon-request/src/request/fetchRequest.ts @@ -0,0 +1,166 @@ +import utils from '../utils/commonUtils/utils'; +import HrError from '../core/HrError'; +import { HrRequestConfig, HrResponse, Cancel } from '../types/interfaces'; +import { Method, ResponseType } from '../types/types'; +import processUploadProgress from './processUploadProgress'; +import processDownloadProgress from './processDownloadProgress'; + +export const fetchRequest = (config: HrRequestConfig): Promise => { + return new Promise((resolve, reject) => { + let { + method = 'GET', + baseURL, + url, + params = null, + data = null, + headers = {}, + responseType, + timeout = 0, + timeoutErrorMessage, + cancelToken = null, + withCredentials = false, + onUploadProgress = null, + onDownloadProgress = null, + } = config; + + let controller = new AbortController(); + let signal = controller.signal; + + // 处理请求取消 + if (cancelToken) { + cancelToken.promise.then((reason: Cancel) => { + controller.abort(); + reject(reason); + }); + } + + // 拼接URL + if (baseURL) { + url = `${baseURL}${url}`; + } + + // 处理请求参数 + if (params) { + const queryString = utils.objectToQueryString(utils.filterUndefinedValues(params)); + url = `${url}${url!.includes('?') ? '&' : '?'}${queryString}`; // 支持用户将部分请求参数写在 url 中 + } + + // GET HEAD 方法不允许设置 body + if (method === 'GET' || method === 'HEAD') { + data = null; + } + + const options = { + method, + headers, + body: data || null, // 防止用户在拦截器传入空字符串,引发 fetch 错误 + signal, + credentials: withCredentials ? 'include' : 'omit', + }; + + if (timeout) { + setTimeout(() => { + controller.abort(); + const errorMsg = timeoutErrorMessage ?? `timeout of ${timeout}ms exceeded`; + const error = new HrError(errorMsg, '', config, undefined, undefined); + reject(error); + }, timeout); + } + + if (!url) { + return Promise.reject('URL is undefined!'); + } + + if (onUploadProgress) { + processUploadProgress(onUploadProgress, data, reject, resolve, method, url, config); + } else { + fetch(url, options as RequestInit) + .then(response => { + + // 将 Headers 对象转换为普通 JavaScript 对象,可以使用 [] 访问具体响应头 + const headersObj = {}; + response.headers.forEach((value, name) => { + headersObj[name] = value; + }); + + config.method = config.method!.toLowerCase() as Method; + + const responseData: HrResponse = { + data: '', + status: response.status, + statusText: response.statusText, + headers: headersObj, + config, + request: null, + }; + + const responseBody = onDownloadProgress + ? processDownloadProgress(response.body, response, onDownloadProgress) + : response.body; + + // 根据 responseType 选择相应的解析方法 + let parseMethod; + + switch (responseType as ResponseType) { + case 'arraybuffer': + parseMethod = new Response(responseBody).arrayBuffer(); + break; + + case 'blob': + parseMethod = new Response(responseBody).blob(); + break; + + // text 和 json 服务端返回的都是字符串 统一处理 + case 'text': + parseMethod = new Response(responseBody).text(); + break; + + case 'json': + parseMethod = new Response(responseBody).text().then((text: string) => { + try { + return JSON.parse(text); + } catch (e) { + // 显式指定返回类型 JSON解析失败报错 + reject('parse error'); + } + }); + break; + default: + parseMethod = new Response(responseBody).text().then((text: string) => { + try { + return JSON.parse(text); + } catch (e) { + // 默认为 JSON 类型,若JSON校验失败则直接返回服务端数据 + return text; + } + }); + } + + parseMethod + .then((parsedData: any) => { + responseData.data = parsedData; + if (responseData.config.validateStatus!(responseData.status)) { + resolve(responseData); + } else { + const error = new HrError(responseData.statusText, '', responseData.config, responseData.request, responseData); + reject(error); + } + }) + .catch((error: HrError) => { + if (error.name === 'AbortError') { + reject(error.message); + } else { + reject(error); + } + }); + }) + .catch((error: HrError) => { + if (error.name === 'AbortError') { + reject(error.message); + } else { + reject(error); + } + }); + } + }); +}; diff --git a/packages/horizon-request/src/request/ieCompatibility/CustomAbortController.ts b/packages/horizon-request/src/request/ieCompatibility/CustomAbortController.ts new file mode 100644 index 00000000..4e4bca63 --- /dev/null +++ b/packages/horizon-request/src/request/ieCompatibility/CustomAbortController.ts @@ -0,0 +1,19 @@ +import CustomAbortSignal from './CustomAbortSignal'; + +class CustomAbortController { + private readonly _signal: CustomAbortSignal; + + constructor() { + this._signal = new CustomAbortSignal(); + } + + get signal(): CustomAbortSignal { + return this._signal; + } + + abort(): void { + this._signal.abort(); + } +} + +export default CustomAbortController; diff --git a/packages/horizon-request/src/request/ieCompatibility/CustomAbortSignal.ts b/packages/horizon-request/src/request/ieCompatibility/CustomAbortSignal.ts new file mode 100644 index 00000000..f5473936 --- /dev/null +++ b/packages/horizon-request/src/request/ieCompatibility/CustomAbortSignal.ts @@ -0,0 +1,30 @@ +class CustomAbortSignal { + private _isAborted: boolean; + private _listeners: Set<() => void>; + + constructor() { + this._isAborted = false; + this._listeners = new Set(); + } + + get aborted(): boolean { + return this._isAborted; + } + + addEventListener(listener: () => void): void { + this._listeners.add(listener); + } + + removeEventListener(listener: () => void): void { + this._listeners.delete(listener); + } + + abort(): void { + if (!this._isAborted) { + this._isAborted = true; + this._listeners.forEach(listener => listener()); + } + } +} + +export default CustomAbortSignal; diff --git a/packages/horizon-request/src/request/ieCompatibility/CustomHeaders.ts b/packages/horizon-request/src/request/ieCompatibility/CustomHeaders.ts new file mode 100644 index 00000000..ce39312a --- /dev/null +++ b/packages/horizon-request/src/request/ieCompatibility/CustomHeaders.ts @@ -0,0 +1,35 @@ +class CustomHeaders { + private _headers: Map; + + constructor(headers?: Record) { + this._headers = new Map(); + if (headers) { + for (const key of Object.keys(headers)) { + this._headers.set(key.toLowerCase(), headers[key]); + } + } + } + + has(name: string): boolean { + return this._headers.has(name.toLowerCase()); + } + + get(name: string): string | null { + const headerValue = this._headers.get(name.toLowerCase()); + return headerValue !== undefined ? headerValue : null; + } + + set(name: string, value: string): void { + this._headers.set(name.toLowerCase(), value); + } + + delete(name: string): void { + this._headers.delete(name.toLowerCase()); + } + + forEach(callback: (value: string, name: string, parent: Map) => void): void { + this._headers.forEach(callback); + } +} + +export default CustomHeaders; diff --git a/packages/horizon-request/src/request/ieCompatibility/CustomResponse.ts b/packages/horizon-request/src/request/ieCompatibility/CustomResponse.ts new file mode 100644 index 00000000..d27387d1 --- /dev/null +++ b/packages/horizon-request/src/request/ieCompatibility/CustomResponse.ts @@ -0,0 +1,35 @@ +import CustomHeaders from './CustomHeaders'; + +class CustomResponse { + private readonly _body: string; + private readonly _status: number; + private readonly _headers: Headers; + + constructor(body: string, init?: { status: number; headers?: Record }) { + this._body = body; + this._status = init?.status || 200; + this._headers = new CustomHeaders(init?.headers) as any; + } + + get status() { + return this._status; + } + + get ok() { + return this.status >= 200 && this.status < 300; + } + + get headers() { + return this._headers; + } + + text(): Promise { + return Promise.resolve(this._body); + } + + json(): Promise { + return Promise.resolve(JSON.parse(this._body)); + } +} + +export default CustomResponse; \ No newline at end of file diff --git a/packages/horizon-request/src/request/ieCompatibility/fetchLike.ts b/packages/horizon-request/src/request/ieCompatibility/fetchLike.ts new file mode 100644 index 00000000..0ff997dc --- /dev/null +++ b/packages/horizon-request/src/request/ieCompatibility/fetchLike.ts @@ -0,0 +1,33 @@ +import { FetchOptions } from '../../types/interfaces'; +import CustomResponse from './CustomResponse'; + +function fetchLike(url: string, options: FetchOptions = {}): Promise { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + const { method = 'GET', headers = {}, body = null } = options; + + xhr.open(method, url, true); + + xhr.onreadystatechange = () => { + if (xhr.readyState === 4) { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(new CustomResponse(xhr.responseText, { status: xhr.status }) as any); + } else { + reject(new Error(`Request failed with status ${xhr.status}`)); + } + } + }; + + xhr.onerror = () => { + reject(new Error('Network error')); + }; + + Object.keys(headers).forEach(key => { + xhr.setRequestHeader(key, headers[key]); + }); + + xhr.send(body); + }); +} + +export default fetchLike; diff --git a/packages/horizon-request/src/request/ieFetchRequest.ts b/packages/horizon-request/src/request/ieFetchRequest.ts new file mode 100644 index 00000000..7e4f7a47 --- /dev/null +++ b/packages/horizon-request/src/request/ieFetchRequest.ts @@ -0,0 +1,149 @@ +import utils from '../utils/commonUtils/utils'; +import HrError from '../core/HrError'; +import CustomAbortController from './ieCompatibility/CustomAbortController'; +import { HrRequestConfig, HrResponse, Cancel } from '../types/interfaces'; +import { Method, ResponseType } from '../types/types'; + +export const ieFetchRequest = (config: HrRequestConfig): Promise => { + return new Promise((resolve, reject) => { + let { + method = 'get', + baseURL, + url, + params = null, + data = null, + headers = {}, + responseType, + timeout = 0, + timeoutErrorMessage, + cancelToken = null, + withCredentials = false, + } = config; + + let controller: any; + let signal; + + // 兼容处理 IE 浏览器AbortController + if (window.AbortController) { + controller = new AbortController(); + signal = controller.signal; + } else { + controller = new CustomAbortController(); + signal = controller.signal; + } + + // 处理请求取消 + if (cancelToken) { + cancelToken.promise.then((reason: Cancel) => { + controller.abort(); + reject(reason); + }); + } + + // 拼接URL + if (baseURL) { + url = `${baseURL}${url}`; + } + + // 处理请求参数 + if (params) { + const queryString = utils.objectToQueryString(params); + url = `${url}?${queryString}`; + } + + // GET HEAD 方法不允许设置body + const options = { + method, + headers, + body: data || null, // 防止用户在拦截器传入空字符串,引发 fetch 错误 + signal, + credentials: withCredentials ? 'include' : 'omit', + }; + + if (timeout) { + setTimeout(() => { + controller.abort(); + reject(new Error(timeoutErrorMessage ?? `timeout of ${timeout}ms exceeded`)); + }, timeout); + } + + if (!url) { + return Promise.reject('URL is undefined!'); + } + + fetch(url, options as RequestInit) + .then(response => { + + config.method = config.method!.toLowerCase() as Method; + + const responseData: HrResponse = { + data: '', + status: response.status, + statusText: response.statusText, + headers: response.headers, + config, + request: null, + }; + + // 根据 responseType 选择相应的解析方法 + let parseMethod; + + switch (responseType as ResponseType) { + case 'arraybuffer': + parseMethod = response.arrayBuffer(); + break; + + case 'blob': + parseMethod = response.blob(); + break; + + // text 和 json 服务端返回的都是字符串 统一处理 + case 'text': + parseMethod = response.text(); + break; + + // 显式指定返回类型 + case 'json': + parseMethod = response.text().then((text: string) => { + try { + return JSON.parse(text); + } catch (e) { + // 显式指定返回类型 JSON解析失败报错 + reject('parse error'); + } + }); + break; + + default: + parseMethod = response.text().then((text: string) => { + try { + return JSON.parse(text); + } catch (e) { + // 默认为 JSON 类型,若JSON校验失败则直接返回服务端数据 + return text; + } + }); + } + + parseMethod + .then((parsedData: any) => { + responseData.data = parsedData; + resolve(responseData); + }) + .catch((error: HrError) => { + if (error.name === 'AbortError') { + reject(error.message); + } else { + reject(error); + } + }); + }) + .catch((error: HrError) => { + if (error.name === 'AbortError') { + reject(error.message); + } else { + reject(error); + } + }); + }); +}; diff --git a/packages/horizon-request/src/request/processDownloadProgress.ts b/packages/horizon-request/src/request/processDownloadProgress.ts new file mode 100644 index 00000000..b46c330c --- /dev/null +++ b/packages/horizon-request/src/request/processDownloadProgress.ts @@ -0,0 +1,31 @@ +function processDownloadProgress(stream: ReadableStream | null, response: Response, onProgress: Function | null) { + // 文件下载过程中更新进度 + if (onProgress) { + const reader = stream?.getReader(); + let totalBytesRead = 0; // 跟踪已读取的字节数 + + return new ReadableStream({ + start(controller) { + function read() { + reader?.read().then(({ done, value }) => { + if (done) { + controller.close(); + return; + } + + totalBytesRead += value.byteLength; + onProgress!({ loaded: totalBytesRead, total: response.headers.get('Content-Length') }); + controller.enqueue(value); // 将读取到的数据块添加到新的 ReadableStream 中 + read(); // 递归调用,继续读取 stream 直到结束 + }); + } + + read(); // 调用 read 函数以启动从原始 stream 中读取数据的过程 + }, + }); + } else { + return stream; + } +} + +export default processDownloadProgress; diff --git a/packages/horizon-request/src/request/processRequest.ts b/packages/horizon-request/src/request/processRequest.ts new file mode 100644 index 00000000..a95dd43e --- /dev/null +++ b/packages/horizon-request/src/request/processRequest.ts @@ -0,0 +1,54 @@ +import CancelError from '../cancel/CancelError'; +import { HrRequestConfig, HrResponse } from '../types/interfaces'; +import HrHeaders from '../core/HrHeaders'; +import transformData from '../utils/dataUtils/transformData'; +import { fetchRequest } from './fetchRequest'; +import transformRequest from '../dataTransformers/transformRequest'; +import transformResponse from '../dataTransformers/transformResponse'; +import checkCancel from '../cancel/checkCancel'; +import { ieFetchRequest } from './ieFetchRequest'; +import utils from '../utils/commonUtils/utils'; + +export default function processRequest(config: HrRequestConfig): Promise { + if (config.cancelToken) { + config.cancelToken.throwIfRequested(); + } + + if (config.signal && config.signal.aborted) { + throw new CancelError(undefined, config); + } + + // 拦截可能会传入普通对象 + config.headers = HrHeaders.from(config.headers as Record); + + // 转换请求数据 + if (config.data) { + config.data = transformData(config, transformRequest); + } + + return (utils.isIE() ? ieFetchRequest : fetchRequest)(config) + .then(response => { + if (config.cancelToken) { + config.cancelToken.throwIfRequested(); + } + + // 转换响应数据 + response.data = transformData(config, transformResponse, response); + + return response; + }) + .catch(error => { + if (!checkCancel(error)) { + if (config.cancelToken) { + config.cancelToken.throwIfRequested(); + } + + // 转换响应数据 + if (error && error.response) { + error.response.data = transformData(config, transformResponse, error.response); + } + } + + return Promise.reject(error); + }); +} diff --git a/packages/horizon-request/src/request/processUploadProgress.ts b/packages/horizon-request/src/request/processUploadProgress.ts new file mode 100644 index 00000000..7fd6479f --- /dev/null +++ b/packages/horizon-request/src/request/processUploadProgress.ts @@ -0,0 +1,94 @@ +import { HrRequestConfig, HrResponse } from '../types/interfaces'; +import HrError from "../core/HrError"; + +function processUploadProgress( + onUploadProgress: Function | null, + data: FormData, + reject: (reason?: any) => void, + resolve: (value: PromiseLike> | HrResponse) => void, + method: string, + url: string | undefined, + config: HrRequestConfig, +) { + if (onUploadProgress && data instanceof FormData) { + let totalBytesToUpload = 0; // 上传的总字节数 + data.forEach(value => { + if (value instanceof Blob) { + totalBytesToUpload += value.size; + } + }); + + const handleUploadProgress = () => { + const xhr = new XMLHttpRequest(); + + // 添加 progress 事件监听器 + xhr.upload.addEventListener('progress', event => { + if (event.lengthComputable) { + // 可以计算上传进度 + onUploadProgress!({ loaded: event.loaded, total: event.total }); + } else { + onUploadProgress!({ loaded: event.loaded, total: totalBytesToUpload }); + } + }); + + // 添加 readystatechange 事件监听器,当 xhr.readyState 变更时执行回调函数 + xhr.addEventListener('readystatechange', () => { + if (xhr.readyState === 4) { + let parsedText; + try { + switch (xhr.responseType) { + case 'json': + parsedText = JSON.parse(xhr.responseText); + break; + case 'text': + default: + parsedText = xhr.responseText; + } + } catch (e) { + reject('parse error'); + } + const response: HrResponse = { + data: parsedText, + status: xhr.status, + statusText: xhr.statusText, + headers: xhr.getAllResponseHeaders(), + config: config, + } + + if (config.validateStatus!(xhr.status)) { + // 如果 fetch 请求已经成功或者拒绝,则此处不生效 + resolve(response); + } else { + const error = new HrError(xhr.statusText, '', config, xhr, response); + reject(error); + } + } + }); + + xhr.open(method, url as string); + + if (config.timeout) { + xhr.timeout = config.timeout; + xhr.ontimeout = function () { + xhr.abort(); + const errorMsg = config.timeoutErrorMessage ?? `timeout of ${config.timeout}ms exceeded`; + throw new HrError(errorMsg, '', config, xhr, undefined); + } + } + + for (const header in config.headers) { + if ( + !['Content-Length', 'Accept-Encoding', 'User-Agent', 'Content-Type'].includes(header) // 过滤不安全的请求头设置 + && Object.prototype.hasOwnProperty.call(config.headers, header) // 不遍历请求头原型上的方法 + ) { + xhr.setRequestHeader(header, config.headers[header]); + } + } + xhr.send(data); + }; + + handleUploadProgress(); // 启动文件上传过程 + } +} + +export default processUploadProgress; diff --git a/packages/horizon-request/src/types/interfaces.ts b/packages/horizon-request/src/types/interfaces.ts new file mode 100644 index 00000000..9a70ab32 --- /dev/null +++ b/packages/horizon-request/src/types/interfaces.ts @@ -0,0 +1,282 @@ +import HrError from '../core/HrError'; +import HrHeaders from '../core/HrHeaders'; +import { Method, ResponseType, HrTransformer, FulfilledFn, RejectedFn, Callback } from './types'; +import CancelError from "../cancel/CancelError"; +import { CanceledError } from "../../index"; + +// 请求配置 +export interface HrRequestConfig { + url?: string; + + method?: Method; + + // 公共URL前缀 + baseURL?: string; + + headers?: Record; + + params?: Record | null; + + data?: any; + + timeout?: number; + + // 超时错误消息 + timeoutErrorMessage?: string; + + // 是否发送凭据 + withCredentials?: boolean; + + // 响应类型 + responseType?: ResponseType; + + // 上传进度事件回调 + onUploadProgress?: (progressEvent: any) => void; + + // 下载进度事件回调 + onDownloadProgress?: (progressEvent: any) => void; + + // 请求取消令牌 + cancelToken?: CancelToken; + + signal?: AbortSignal; + + // 过渡选项 + transitional?: TransitionalOptions; + + validateStatus?: (status: number) => boolean; +} + +export interface TransitionalOptions { + // 是否忽略 JSON parse 的错误配置 + silentJSONParsing?: boolean; + + // 强制解析为 JSON 格式 + forcedJSONParsing?: boolean; + + // 请求超时异常错误配置 + clarifyTimeoutError?: boolean; +} + +// 请求响应 +export type HrResponse = { + // 响应数据 + data: T; + + // 响应状态码 + status: number; + + // 响应状态消息 + statusText: string; + + // 响应头 + headers: any; + + // 请求配置 + config: HrRequestConfig; + + // 请求对象 + request?: any; + + // 响应事件消息 + event?: string; +}; + +// Hr 类接口类型 +export interface HrInterface { + request(url: string | Record, config?: HrRequestConfig): Promise>; + + get(url: string, config?: HrRequestConfig): Promise>; + + post(url: string, data?: any, config?: HrRequestConfig): Promise>; + + put(url: string, data?: any, config?: HrRequestConfig): Promise>; + + delete(url: string, config?: HrRequestConfig): Promise>; + + head(url: string, config?: HrRequestConfig): Promise>; + + options(url: string, config?: HrRequestConfig): Promise>; + + postForm(url: string, data: any, config: HrRequestConfig): Promise>; + + putForm(url: string, data: any, config: HrRequestConfig): Promise>; + + patchForm(url: string, data: any, config: HrRequestConfig): Promise>; +} + +// Hr 实例接口类型 +export interface HrInstance extends HrInterface { + // Hr 类 + HorizonRequest: HrInterface; + + // 创建 Hr 实例 + create: (config?: HrRequestConfig) => HrInstance; + + // 使用内置的配置初始化实例属性 + defaults: HrRequestConfig; + + // 取消当前正在进行的请求 + CancelToken: CancelTokenStatic; + + // 判断是否请求取消 + isCancel: (value: any) => boolean; + + // CanceledError的别名,用于向后兼容 + Cancel: typeof CanceledError; + + // 实例拦截请求 + interceptors: Interceptors; + + // 并发发送多个 HTTP 请求 + all(promises: Array>): Promise>; + + // 封装多个 Promise 至数组,便于作为 all 传入参数 + spread: (callback: Callback) => (arr: any[]) => T; + + // horizonRequest 对象的默认实例 + default: HrInstance; + + CanceledError: typeof CancelError; + + // HrError 错误 + HrError: typeof HrError; + + // 判断输入值是否为 HrError + isHrError: (avl: any) => boolean; + + // HrHeaders 响应头 + HrHeaders: typeof HrHeaders; + + useHR: (url: string, config?: HrRequestConfig, options?: QueryOptions) => { data?: T; error?: any }; + + /*----------------兼容axios--------------------*/ + Axios: any; + + AxiosError: any; + + isAxiosError: (val: any) => boolean; + + AxiosHeaders: any; +} + +export interface Interceptors { + request: HrInterceptorManager; + response: HrInterceptorManager; +} + +// 拦截器接口类型 +export interface InterceptorHandler { + // Promise 成功时,拦截器处理响应函数 + fulfilled?: FulfilledFn; + + // Promise 拒绝时,拦截器处理响应值函数 + rejected?: RejectedFn; + + // 截器是否在单线程中执行 + synchronous?: boolean; + + // 拦截器何时被执行 + runWhen?: (value: T) => boolean; +} + +// 拦截器管理器接口类型 +export interface HrInterceptorManager { + // 添加拦截器 + use( + fulfilled?: FulfilledFn, + rejected?: RejectedFn, + options?: { + synchronous?: boolean; + runWhen?: (value: T) => boolean; + } + ): number; + + // 移除拦截器 + eject(id: number): void; + + // 清除拦截器 + clear(): void; + + // 过滤跳过迭代器 + forEach(func: Function): void; +} + +export interface HrErrorInterface { + // 产生错误的请求配置对象 + config?: HrRequestConfig; + + // 表示请求错误的字符串代码。例如,"ECONNABORTED"表示连接被中止。 + code?: string; + + // 产生错误的原始请求实例。 + request?: HrInstance; + + // 包含错误响应的响应实例。如果请求成功完成,但服务器返回错误状态码(例如404或500),则此属性存在。 + response?: HrResponse; +} + +// 请求取消令牌 +export interface CancelToken { + // 可取消的 Promise,在超时时间 (或其他原因) 结束时被解决,并返回一个字符串值,该字符串值将作为取消请求的标识符 + promise: Promise; + + // 取消请求的标识符 + reason?: Cancel; + + // 如果请求被取消,则会抛出错误 + throwIfRequested(): void; +} + +export interface Cancel { + message?: string; + cancelFlag?: boolean; +} + +interface CancelExecutor { + (cancel: Cancel): void; +} + +interface CancelTokenSource { + token: CancelToken; + cancel: Cancel; +} + +export interface CancelTokenStatic { + new (executor: CancelExecutor): CancelToken; + + source(): CancelTokenSource; +} + +// 兼容 IE fetchLike 接口类型 +export interface FetchOptions { + method?: Method; + headers?: Record; + body?: any; +} + +// 轮询查询配置 轮询间隔(毫秒) +export interface QueryOptions { + pollingInterval?: number; + // 是否启用动态轮询策略 + enablePollingOptimization?: boolean; + // 配置动态轮询策略后生效 + limitation?: Limitation; + // 动态轮询策略分析历史数据容量,默认100 + capacity?: number; + // 动态轮询策略窗口大小,默认5 + windowSize?: number; +} + +export interface Limitation { + minInterval: number, + maxInterval: number, +} + +// useHR 缓存 +export interface CacheItem { + data: any; + lastUpdated: number; + pollingInterval?: number; + timeoutId?: any; +} diff --git a/packages/horizon-request/src/types/types.ts b/packages/horizon-request/src/types/types.ts new file mode 100644 index 00000000..3a426d34 --- /dev/null +++ b/packages/horizon-request/src/types/types.ts @@ -0,0 +1,47 @@ +import HrHeaders from '../core/HrHeaders'; +import { HrRequestConfig, HrResponse } from './interfaces'; + +export type Method = + | 'get' + | 'GET' + | 'delete' + | 'DELETE' + | 'head' + | 'HEAD' + | 'options' + | 'OPTIONS' + | 'post' + | 'POST' + | 'put' + | 'PUT' + | 'patch' + | 'PATCH'; + +export type ResponseType = 'text' | 'json' | 'blob' | 'arraybuffer'; + +// 请求和响应数据转换器 +export type HrTransformer = (data: any, headers?: HrHeaders) => any; + +// Headers +export type HeaderMap = Record; +export type HeaderMatcher = boolean | RegExp | Function; + +// Promise 成功和拒绝类型 +export type FulfilledFn = (value: T) => T | Promise; // 泛型确保了拦截器链中各个环节之间的一致性,避免数据类型不匹配引发的错误 +export type RejectedFn = (error: any) => any; + +// 过滤器 +export type FilterFunc = (obj: Record, destObj: Record) => boolean; +export type PropFilterFunc = (prop: string | symbol, obj: Record, destObj: Record) => boolean; + +export type ObjectDescriptor = PropertyDescriptorMap & ThisType; + +// Cancel +export type CancelFunction = (message?: string) => void; +export type CancelExecutor = (cancel: CancelFunction) => void; + +export type Callback = (...args: any[]) => T; + +export type Strategy = { + (data: any, headers: HrHeaders, ...args: any[]): any; +}; diff --git a/packages/horizon-request/src/utils/commonUtils/utils.ts b/packages/horizon-request/src/utils/commonUtils/utils.ts new file mode 100644 index 00000000..2ef42caf --- /dev/null +++ b/packages/horizon-request/src/utils/commonUtils/utils.ts @@ -0,0 +1,454 @@ +import { Callback, FilterFunc, PropFilterFunc } from '../../types/types'; + +/** + * 将函数 func 绑定到指定的 this 上下文对象 thisArg + * + * @param func 需要绑定上下文的函数 + * @param thisArg 上下文对象 + * + * @returns {any} 新的函数,当这个新的函数被调用时,它会调用 func 函数,并使用 apply() 方法将 thisArg 作为上下文对象,将传递的参数作为 func 函数的参数传递给它 + */ +function bind(func: Function, thisArg: any): (...args: any[]) => any { + return (...args: any[]) => func.apply(thisArg, args); +} + +/** + * 获取输入的变量类型 + * + * @param input 需要判断类型的变量 + * + * @returns {string} 输入变量的类型名称 + */ +function getType(input: any): string { + const str: string = Object.prototype.toString.call(input); + return str.slice(8, -1).toLowerCase(); +} + +/** + * 输入类型名称构造判断相应类型的函数 + * + * @param type 需要判断的类型名称 + * + * @returns {Function} 工厂函数,用以判断输入值是否为当前类型 + */ +const createTypeChecker = (type: string) => (input: any) => getType(input) === type.toLowerCase(); + +const checkString = createTypeChecker('string'); + +const checkFunction = createTypeChecker('function'); + +const checkNumber = createTypeChecker('number'); + +const checkObject = (input: any) => input !== null && typeof input === 'object'; + +const checkBoolean = (input: any) => input === true || input === false; + +const checkUndefined = createTypeChecker('undefined'); + +/** + * 判断变量类型是否为纯对象 + * + * @param input 需要判断的变量 + * + * @returns {boolean} 如果变量为纯对象 则返回 true,否则返回 false + */ +const checkPlainObject = (input: any) => { + if (Object.prototype.toString.call(input) !== '[object Object]') { + return false; + } + + const prototype = Object.getPrototypeOf(input); + return prototype === null || prototype === Object.prototype; +}; + +const checkDate = createTypeChecker('Date'); + +const checkFile = createTypeChecker('File'); + +const checkBlob = createTypeChecker('Blob'); + +const checkStream = (input: any) => checkObject(input) && checkFunction(input.pipe); + +const checkFileList = createTypeChecker('FileList'); + +const checkFormData = (input: any) => input instanceof FormData; + +const checkURLSearchParams = createTypeChecker('URLSearchParams'); + +const checkRegExp = createTypeChecker('RegExp'); + +const checkHTMLForm = createTypeChecker('HTMLFormElement'); + +/** + * 对数组或对象中的每个元素执行指定的回调函数 + * + * @param input 待遍历的数组或对象 + * @param func 用于处理每个元素的回调函数 + * @param options 可选配置项,用于控制遍历过程 + * + * @returns {void} 无返回值 + * + * @template T + */ +function forEach( + input: T | T[] | Record | null | undefined, + func: Function, + options: { includeAll?: boolean } = {} +): void { + if (input === null || input === undefined) { + return; + } + + const { includeAll = false } = options; + + if (typeof input !== 'object') { + input = [input] as T[]; + } + + if (Array.isArray(input)) { + (input as T[]).forEach((value, index, array) => { + func.call(null, value, index, array); + }); + } else { + const keys = includeAll + ? getAllPropertyNames(input as Record) + : Object.keys(input as Record); + keys.forEach(key => { + func.call(null, (input as Record)[key], key, input!); + }); + } +} + +/** + * 查找给定对象中与指定键名相等(忽略大小写)的键名,并返回该键名,如果对象中不存在与指定键名相等的键名,则返回 null + * + * @param obj 待查找的对象 + * @param key 要查找的键名 + * + * @returns {string | null} 与指定键名相等的键名,或者 null + */ +function getObjectKey(obj: Record, key: string): string | null { + const _key = key.toLowerCase(); + const keys = Object.keys(obj); + for (let i = 0; i < keys.length; i++) { + const k = keys[i]; + if (k.toLowerCase() === _key) { + return k; + } + } + return null; +} + +/** + * 将源对象的属性合并到目标对象中 + * + * @param target 目标对象,表示要将属性合并到这个对象中 + * @param source 源对象,表示要从这个对象中提取属性,并合并到目标对象中 + * @param thisArg 可选参数,表示要将函数类型的属性绑定到这个对象上,以便在调用时可以正确设置 `this` 上下文 + * @param options 可选参数,一个配置对象,用于控制是否仅遍历对象自身的属性而不包括继承的属性 + * + * @returns {Record} 返回目标对象,表示合并后的对象 + */ +function extendObject( + target: Record, + source: Record, + thisArg?: any, + options?: { includeAll?: boolean } +) { + const { includeAll = false } = options || {}; + + forEach(source, (val: any, key: any) => { + if (thisArg && checkFunction(val)) { + target[key as number] = bind(val, thisArg); + } else { + target[key as number] = val; + } + // @ts-ignore + }, { includeAll: includeAll }); + + return target; +} + +/** + * 获取对象所有属性名,包括原型 + * + * @param obj 需要获取的对象 + * + * @returns {string[]} 所有属性名数组 + */ +function getAllPropertyNames(obj: Record): string[] { + let result: string[] = []; + let currentObj = obj; + while (currentObj) { + const propNames = Object.getOwnPropertyNames(currentObj); + propNames.forEach(propName => { + if (result.indexOf(propName) === -1) { + result.push(propName); + } + }); + currentObj = Object.getPrototypeOf(currentObj); + } + return result; +} + +/** + * 将给定对象转换为扁平对象,通过展平嵌套对象和数组 + * 嵌套对象通过连接键使用分隔符展平 + * 嵌套数组通过使用分隔符将索引附加到父键上进行展平 + * + * @param sourceObj 要展平的对象 + * @param destObj 目标对象,用于存储扁平化后的对象,如果未提供则会创建一个新对象 + * @param filter 控制是否继续展平父对象的回调函数,可选 + * @param propFilter 控制哪些属性不进行展平的回调函数,可选 + * + * @returns {Record} 一个新对象,它是输入对象的扁平表示 + */ +function flattenObject( + sourceObj: Record | null | undefined, + destObj: Record = {}, + filter?: FilterFunc, + propFilter?: PropFilterFunc +): Record { + let props: (string | symbol)[]; + let i: number; + let prop: string | symbol; + const merged: Record = {}; + + if (sourceObj === null || sourceObj === undefined) { + return destObj; + } + + do { + props = getAllPropertyNames(sourceObj); + i = props.length; + + while (i-- > 0) { + prop = props[i]; + + if ((!propFilter || propFilter(prop, sourceObj, destObj)) && !merged[prop as any]) { + destObj[prop as any] = sourceObj[prop as any]; + merged[prop as any] = true; + } + } + + sourceObj = filter && Object.getPrototypeOf(sourceObj); + } while (sourceObj && (!filter || filter(sourceObj, destObj)) && sourceObj !== Object.prototype); + + return destObj; +} + +/** + * 迭代一个对象,对其所有的属性(包括继承来的)进行迭代,并对每个属性值调用回调函数 + * + * @param obj 待迭代的对象 + * @param func 迭代时对每个属性值调用的回调函数 + * + * @returns {void} 无返回值 + */ +function forEachEntry(obj: Record, func: (key: any, val: any) => void) { + if (obj instanceof Map) { + obj.forEach((value, key) => func(key, value)); + } else { + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + func(key, obj[key]); + } + } + } +} + +/** + * 将给定的字符串或字符串数组转换为一个对象,该对象的键为字符串或字符串数组中的唯一值,值为布尔值 `true` + * + * @param input 给定的字符串或字符串数组。 + * @param delimiter 当 `arrayOrString` 参数是字符串时,指定用于拆分字符串的分隔符 + * + * @returns {Record} 一个对象,其中包含唯一的字符串值,并且每个值的键对应于输入的字符串或字符串数组 + */ +const toBooleanObject = (input: string | string[], delimiter?: string): Record => { + const obj: Record = {}; + + const define = (arr: string[]) => { + arr.forEach(value => { + obj[value.trim()] = true; + }); + }; + + if (Array.isArray(input)) { + define(input); + } else { + const stringArray = input.split(delimiter || ''); + define(stringArray); + } + + return obj; +}; + +/** + * 将一个对象转换为 JSON 对象,安全地处理循环引用 + * + * @param obj 要转换的对象 + * + * @returns {Record |null} 转换后的 JSON 对象 + */ +const toJSONSafe = (obj: Record): Record | null => { + const visited = new WeakSet(); + + const visit = (source: Record): Record | null => { + if (checkObject(source)) { + if (visited.has(source)) { + return null; + } + visited.add(source); + + if ('toJSON' in source) { + return (source as any).toJSON(); + } + + const target: any = Array.isArray(source) ? [] : {}; + + for (const [key, value] of Object.entries(source)) { + const reducedValue = visit(value); + if (!checkUndefined(reducedValue)) { + target[key] = reducedValue; + } + } + + return target; + } + + return source; + }; + + return visit(obj); +}; + +/** + * 安全地将一个值转换为 JSON 字符串,解析无效的 JSON 字符串时不会抛出 SyntaxError + * + * @template T + * + * @param rawValue 要转换为 JSON 字符串的值,可以是一个字符串或任何其他类型的值 + * @param parser 自定义的 JSON 解析函数。默认为 `JSON.parse` + * @param encoder 自定义的 JSON 编码函数。默认为 `JSON.stringify` + * + * @returns {string} 生成的 JSON 字符串 + * + * @throws {Error} 如果在解析过程中出现非 SyntaxError 类型的错误,则会抛出该错误 + */ +function stringifySafely( + rawValue: any, + parser?: (jsonString: string) => T, + encoder: (value: T) => string = JSON.stringify +): string { + if (typeof rawValue === 'string') { + try { + (parser ?? JSON.parse)(rawValue); + return rawValue.trim(); + } catch (e) { + if ((e as Error).name !== 'SyntaxError') { + throw e; + } + } + } + + return encoder(rawValue); +} + +/** + * 将一个字符串转换为驼峰式命名法 + * + * @param str 要转换的字符串 + * + * @returns {string} 转换后的字符串 + */ +const convertToCamelCase = (str: string) => { + return str + .split(/[-_\s]/) + .map((word, index) => (index === 0 ? word : word.charAt(0).toUpperCase() + word.slice(1))) + .join(''); +}; + +function objectToQueryString(obj: Record) { + return Object.keys(obj) + .map(key => encodeURIComponent(key) + '=' + encodeURIComponent(obj[key])) + .join('&'); +} + +const all = (promises: Array>): Promise => Promise.all(promises); + +function spread(callback: Callback): (arr: any[]) => T { + return (arr: any[]): T => callback.apply(null, arr); +} + +function getNormalizedValue(value: string | any[] | boolean | null | number): string | any[] | boolean | null { + if ( + value === false + || value === null + || value === undefined + ) { + return value; + } + + return Array.isArray(value) ? value.map(item => getNormalizedValue(item) as string) : String(value); +} + +function isIE(): boolean { + return /MSIE|Trident/.test(window.navigator.userAgent); +} + +function getObjectByArray(arr: any[]): Record { + return arr.reduce((obj, item, index) => { + obj[index] = item; + return obj; + }, {}); +} + +function filterUndefinedValues(obj: Record) { + return Object.keys(obj).reduce((result, key) => { + if (obj[key] !== undefined) { + result[key] = obj[key]; + } + return result; + }, {}); +} + +const utils = { + bind, + checkFormData, + checkString, + checkNumber, + checkBoolean, + checkObject, + checkPlainObject, + checkUndefined, + checkDate, + checkFile, + checkBlob, + checkStream, + checkRegExp, + checkFunction, + checkURLSearchParams, + checkFileList, + checkHTMLForm, + forEach, + extendObject, + flattenObject, + getType, + createTypeChecker, + forEachEntry, + toBooleanObject, + getObjectKey, + toJSONSafe, + stringifySafely, + convertToCamelCase, + objectToQueryString, + spread, + all, + getNormalizedValue, + isIE, + getObjectByArray, + filterUndefinedValues, +}; + +export default utils; diff --git a/packages/horizon-request/src/utils/configUtils/deepMerge.ts b/packages/horizon-request/src/utils/configUtils/deepMerge.ts new file mode 100644 index 00000000..347ebdd1 --- /dev/null +++ b/packages/horizon-request/src/utils/configUtils/deepMerge.ts @@ -0,0 +1,34 @@ +import utils from '../commonUtils/utils'; + +// 获取当前上下文对象 +function getContextObject(): any { + if (typeof globalThis !== 'undefined') return globalThis; + return typeof self !== 'undefined' ? self : typeof window !== 'undefined' ? window : global; +} + +// 合并多个对象为一个新对象:1.如果有多个对象的属性名相同,则后面的对象的属性值会覆盖前面的对象的属性值;2.如果一个对象的属性值是另一个对象,则会递归合并两个对象的属性值;3.如果一个对象的属性值是数组,则会拷贝一个新的数组,防止修改原数组 +function deepMerge(...objects: Record[]): Record { + const context = getContextObject(); + const { caseless } = context || {}; + const result: any = {}; + + const assignValue = (val: any, key: any) => { + const targetKey = caseless ? utils.getObjectKey(result, key) || key : key; + if (utils.checkPlainObject(result[targetKey]) && utils.checkPlainObject(val)) { + result[targetKey] = deepMerge(result[targetKey], val); + } else if (utils.checkPlainObject(val)) { + result[targetKey] = deepMerge({}, val); + } else if (Array.isArray(val)) { + result[targetKey] = val.slice(); + } else { + result[targetKey] = val; + } + }; + + for (const obj of objects) { + obj && utils.forEach(obj, assignValue); + } + return result; +} + +export default deepMerge; \ No newline at end of file diff --git a/packages/horizon-request/src/utils/configUtils/getMergedConfig.ts b/packages/horizon-request/src/utils/configUtils/getMergedConfig.ts new file mode 100644 index 00000000..1a217321 --- /dev/null +++ b/packages/horizon-request/src/utils/configUtils/getMergedConfig.ts @@ -0,0 +1,40 @@ +import utils from '../commonUtils/utils'; +import deepMerge from './deepMerge'; + +function getMergedConfig(config1: Record, config2: Record): Record { + config2 = config2 || {}; + + // 定义一个默认的合并策略函数,用于返回源对象的属性值,如果源对象的属性值为 undefined,则返回目标对象的属性值 + const defaultMergeStrategy = (a: any, b: any) => (b !== undefined ? b : a); + + // 创建一个对象,用于存储每个属性的合并策略 + const mergeStrategies: Record any> = { + url: defaultMergeStrategy, + method: defaultMergeStrategy, + baseURL: defaultMergeStrategy, + data: defaultMergeStrategy, + params: defaultMergeStrategy, + headers: (a: any, b: any) => deepMerge(a || {}, b || {}), + timeout: defaultMergeStrategy, + responseType: defaultMergeStrategy, + onUploadProgress: defaultMergeStrategy, + onDownloadProgress: defaultMergeStrategy, + cancelToken: defaultMergeStrategy, + }; + + // 使用 deepMerge 函数将 config1 的属性合并到一个新的空对象中,创建一个名为 mergedConfig 的新对象,用于存储合并后的配置 + const mergedConfig = deepMerge({}, config1); + + for (const key in config2) { + // 从 mergeStrategies 中获取适当的合并策略函数,如果没有特定的合并策略,使用默认的 defaultMergeStrategy 函数 + const mergeStrategy = mergeStrategies[key] || defaultMergeStrategy; + + // 使用合并策略函数计算合并后的属性值,并将其添加到结果配置对象 mergedConfig 中 + mergedConfig[key] = mergeStrategy(config1[key], config2[key]); + } + + // 返回合并后的配置对象 mergedConfig + return mergedConfig; +} + +export default getMergedConfig; diff --git a/packages/horizon-request/src/utils/dataUtils/getFormData.ts b/packages/horizon-request/src/utils/dataUtils/getFormData.ts new file mode 100644 index 00000000..26c9f869 --- /dev/null +++ b/packages/horizon-request/src/utils/dataUtils/getFormData.ts @@ -0,0 +1,19 @@ +function getFormData(obj: Record, formData: FormData = new FormData()): FormData { + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + const value = obj[key]; + + if (Array.isArray(value)) { + for (const item of value) { + formData.append(key, item.toString()); + } + } else { + formData.append(key, value.toString()); + } + } + } + + return formData; +} + +export default getFormData; diff --git a/packages/horizon-request/src/utils/dataUtils/getJSONByFormData.ts b/packages/horizon-request/src/utils/dataUtils/getJSONByFormData.ts new file mode 100644 index 00000000..6083aa3a --- /dev/null +++ b/packages/horizon-request/src/utils/dataUtils/getJSONByFormData.ts @@ -0,0 +1,28 @@ +import utils from '../commonUtils/utils'; + +export function parsePath(name: string): string[] { + const matches = Array.from(name.matchAll(/\w+|\[(\w*)]/g)); + const arr = []; + + for (const match of matches) { + const matchValue = match[0] === '[]' ? '' : match[1] || match[0]; + arr.push(matchValue); + } + + return arr; +} + +function getJSONByFormData(formData: FormData): Record | null { + if (utils.checkFormData(formData) && utils.checkFunction((formData as any).entries)) { + const obj: Record = {}; + + for (const [key, value] of (formData as any).entries()) { + obj[key] = value; + } + return obj; + } + + return null; +} + +export default getJSONByFormData; diff --git a/packages/horizon-request/src/utils/dataUtils/transformData.ts b/packages/horizon-request/src/utils/dataUtils/transformData.ts new file mode 100644 index 00000000..a6495bf3 --- /dev/null +++ b/packages/horizon-request/src/utils/dataUtils/transformData.ts @@ -0,0 +1,16 @@ +import HrHeaders from '../../core/HrHeaders'; +import defaultConfig from '../../config/defaultConfig'; +import { HrRequestConfig, HrResponse } from '../../types/interfaces'; + +function transformData(inputConfig: HrRequestConfig, func: Function, response?: HrResponse) { + const config = inputConfig || defaultConfig; + const context = response || config; + const headers = HrHeaders.from(context.headers); + + const transformedData = func.call(config, context.data, headers.normalize(), response ? response.status : undefined); + headers.normalize(); + + return transformedData; +} + +export default transformData; diff --git a/packages/horizon-request/src/utils/headerUtils/checkHeaderName.ts b/packages/horizon-request/src/utils/headerUtils/checkHeaderName.ts new file mode 100644 index 00000000..da43ef84 --- /dev/null +++ b/packages/horizon-request/src/utils/headerUtils/checkHeaderName.ts @@ -0,0 +1,5 @@ +function checkHeaderName(str: string) { + return /^[-_a-zA-Z]+$/.test(str.trim()); +} + +export default checkHeaderName; diff --git a/packages/horizon-request/src/utils/headerUtils/convertRawHeaders.ts b/packages/horizon-request/src/utils/headerUtils/convertRawHeaders.ts new file mode 100644 index 00000000..4ec9f221 --- /dev/null +++ b/packages/horizon-request/src/utils/headerUtils/convertRawHeaders.ts @@ -0,0 +1,32 @@ +import { HeaderMap } from '../../types/types'; + +function convertRawHeaders(rawHeaders: string): HeaderMap { + const convertedHeaders: HeaderMap = {}; + + if (rawHeaders) { + rawHeaders.split('\n').forEach((item: string) => { + let i = item.indexOf(':'); + let key = item.substring(0, i).trim().toLowerCase(); + let val = item.substring(i + 1).trim(); + + if (!key || (convertedHeaders[key] && key !== 'set-cookie')) { + return; + } + + // Set-Cookie 在 HTTP 响应中可能会出现多次,为了保留每个独立的 Set-Cookie 报头,需要将它们的值存储在一个数组中 + if (key === 'set-cookie') { + if (convertedHeaders[key]) { + (convertedHeaders[key] as any).push(val); + } else { + convertedHeaders[key] = [val]; + } + } else { + convertedHeaders[key] = convertedHeaders[key] ? `${convertedHeaders[key]}, ${val}` : val; + } + }); + } + + return convertedHeaders; +} + +export default convertRawHeaders; diff --git a/packages/horizon-request/src/utils/headerUtils/deleteHeader.ts b/packages/horizon-request/src/utils/headerUtils/deleteHeader.ts new file mode 100644 index 00000000..dc7239a0 --- /dev/null +++ b/packages/horizon-request/src/utils/headerUtils/deleteHeader.ts @@ -0,0 +1,18 @@ +import utils from '../commonUtils/utils'; + +function deleteHeader(this: any, header: string) { + const normalizedHeader = String(header).trim().toLowerCase(); + + if (normalizedHeader) { + const key = utils.getObjectKey(this, normalizedHeader); + + if (key) { + delete this[key]; + return true; + } + } + + return false; +} + +export default deleteHeader; diff --git a/packages/horizon-request/src/utils/headerUtils/processValueByParser.ts b/packages/horizon-request/src/utils/headerUtils/processValueByParser.ts new file mode 100644 index 00000000..795c5cc2 --- /dev/null +++ b/packages/horizon-request/src/utils/headerUtils/processValueByParser.ts @@ -0,0 +1,35 @@ +import utils from '../commonUtils/utils'; +import { HeaderMatcher } from '../../types/types'; + +// 解析类似“key=value”格式的字符串,并将解析结果以对象的形式返回 +export function parseKeyValuePairs(str: string): Record { + const parsedObj: Record = {}; + const matcher = /([^\s,;=]+)\s*(?:=\s*([^,;]+))?/g; + + let match: RegExpExecArray | null; + while ((match = matcher.exec(str))) { + const [, key, value] = match; + parsedObj[key?.trim()] = value?.trim(); + } + + return parsedObj; +} + +function processValueByParser(key: string, value: any, parser?: HeaderMatcher): any { + if (!parser) { + return value; + } + if (parser === true) { + return parseKeyValuePairs(value); + } + if (utils.checkFunction(parser)) { + return (parser as Function)(value, key); + } + if (utils.checkRegExp(parser)) { + return (parser as RegExp).exec(value)?.filter(item => item !== undefined); + } + + throw new TypeError('parser is not correct!'); +} + +export default processValueByParser; diff --git a/packages/horizon-request/src/utils/instanceUtils/buildInstance.ts b/packages/horizon-request/src/utils/instanceUtils/buildInstance.ts new file mode 100644 index 00000000..8aa424fd --- /dev/null +++ b/packages/horizon-request/src/utils/instanceUtils/buildInstance.ts @@ -0,0 +1,15 @@ +import { HrInstance, HrRequestConfig } from '../../types/interfaces'; +import HorizonRequest from '../../core/HorizonRequest'; +import extendInstance from './extendInstance'; + +function buildInstance(config: HrRequestConfig): HrInstance { + // 使用上下文 context 将请求和响应的配置和状态集中在一个地方,使得整个 Hr 实例可以共享这些配置和状态,避免了在多个地方重复定义和管理这些配置和状态 + const context = new HorizonRequest(config); + + // 将 Hr.prototype.request 方法上下文绑定到 context 上下文,将一个新的函数返回,并将这个新的函数保存到一个 instance 常量中,可以在 instance 实例上使用 Hr 类原型上的方法和属性,同时又可以保证这些方法和属性在当前实例上下文中正确地执行 + const instance = extendInstance(context); + + return instance as any; +} + +export default buildInstance; diff --git a/packages/horizon-request/src/utils/instanceUtils/extendInstance.ts b/packages/horizon-request/src/utils/instanceUtils/extendInstance.ts new file mode 100644 index 00000000..79b32dfe --- /dev/null +++ b/packages/horizon-request/src/utils/instanceUtils/extendInstance.ts @@ -0,0 +1,11 @@ +import HorizonRequest from '../../core/HorizonRequest'; +import utils from '../commonUtils/utils'; + +function extendInstance(context: HorizonRequest): (...arg: any) => any { + const instance = utils.bind(HorizonRequest.prototype.request, context); + utils.extendObject(instance, HorizonRequest.prototype, context, { includeAll: true }); + utils.extendObject(instance, context, null, { includeAll: true }); + return instance; +} + +export default extendInstance; diff --git a/packages/horizon-request/tests/unitTest/utils/commonUtils/bind.test.ts b/packages/horizon-request/tests/unitTest/utils/commonUtils/bind.test.ts new file mode 100644 index 00000000..b957c8fe --- /dev/null +++ b/packages/horizon-request/tests/unitTest/utils/commonUtils/bind.test.ts @@ -0,0 +1,28 @@ +import utils from '../../../../src/utils/commonUtils/utils'; + +describe('bind function', () => { + it('should return a new function', () => { + const fn = () => { + }; + const boundFn = utils.bind(fn, {}); + expect(boundFn).toBeInstanceOf(Function); + expect(boundFn).not.toBe(fn); + }); + + it('should call original function with correct this value', () => { + const thisArg = { name: 'Alice' }; + const fn = function (this: any) { + return this["name"]; + }; + const boundFn = utils.bind(fn, thisArg); + const result = boundFn(); + expect(result).toBe('Alice'); + }); + + it('should pass arguments to the original function', () => { + const fn = (a: number, b: number) => a + b; + const boundFn = utils.bind(fn, {}); + const result = boundFn(2, 3); + expect(result).toBe(5); + }); +}); diff --git a/packages/horizon-request/tests/unitTest/utils/commonUtils/convertToCamelCase.test.ts b/packages/horizon-request/tests/unitTest/utils/commonUtils/convertToCamelCase.test.ts new file mode 100644 index 00000000..faaba158 --- /dev/null +++ b/packages/horizon-request/tests/unitTest/utils/commonUtils/convertToCamelCase.test.ts @@ -0,0 +1,45 @@ +import utils from '../../../../src/utils/commonUtils/utils'; + +describe('convertToCamelCase function', () => { + it('should convert kebab case to camel case', () => { + const input = 'my-first-post'; + const expectedOutput = 'myFirstPost'; + const result = utils.convertToCamelCase(input); + expect(result).toBe(expectedOutput); + }); + + it('should convert snake case to camel case', () => { + const input = 'my_snake_case_post'; + const expectedOutput = 'mySnakeCasePost'; + const result = utils.convertToCamelCase(input); + expect(result).toBe(expectedOutput); + }); + + it('should convert space separated words to camel case', () => { + const input = 'my space separated words'; + const expectedOutput = 'mySpaceSeparatedWords'; + const result = utils.convertToCamelCase(input); + expect(result).toBe(expectedOutput); + }); + + it('should handle already camel cased words', () => { + const input = 'myCamelCasedWords'; + const expectedOutput = 'myCamelCasedWords'; + const result = utils.convertToCamelCase(input); + expect(result).toBe(expectedOutput); + }); + + it('should handle empty input', () => { + const input = ''; + const expectedOutput = ''; + const result = utils.convertToCamelCase(input); + expect(result).toBe(expectedOutput); + }); + + it('should handle input with only one word', () => { + const input = 'hello'; + const expectedOutput = 'hello'; + const result = utils.convertToCamelCase(input); + expect(result).toBe(expectedOutput); + }); +}); diff --git a/packages/horizon-request/tests/unitTest/utils/commonUtils/createTypeChecker.test.ts b/packages/horizon-request/tests/unitTest/utils/commonUtils/createTypeChecker.test.ts new file mode 100644 index 00000000..929a33f9 --- /dev/null +++ b/packages/horizon-request/tests/unitTest/utils/commonUtils/createTypeChecker.test.ts @@ -0,0 +1,107 @@ +import utils from '../../../../src/utils/commonUtils/utils'; + +describe('createTypeChecker function', () => { + it('should return a function', () => { + const result = utils.createTypeChecker('string'); + expect(result).toBeInstanceOf(Function); + }); + + it('should return true for matching type', () => { + const isString = utils.createTypeChecker('string'); + const result = isString('hello'); + expect(result).toBe(true); + }); + + it('should return false for non-matching type', () => { + const isString = utils.createTypeChecker('string'); + const result = isString(123); + expect(result).toBe(false); + }); + + it('should return true for matching type', () => { + const result = utils.checkFunction((a: number, b: number) => a + b); + expect(result).toBe(true); + }); + + it('should return false for non-matching type', () => { + const result = utils.checkFunction(123); + expect(result).toBe(false); + }); + + it('should return true for matching type', () => { + const result = utils.checkNumber(123); + expect(result).toBe(true); + }); + + it('should return false for non-matching type', () => { + const result = utils.checkNumber('123'); + expect(result).toBe(false); + }); + + it('should return true for matching type', () => { + const result = utils.checkObject({ name: 'Tom' }); + expect(result).toBe(true); + }); + + it('should return false for non-matching type', () => { + const result = utils.checkObject('123'); + expect(result).toBe(false); + }); + + it('should return true for matching type', () => { + const result = utils.checkBoolean(true); + expect(result).toBe(true); + }); + + it('should return false for non-matching type', () => { + const result = utils.checkBoolean('123'); + expect(result).toBe(false); + }); + + it('should return true for matching type', () => { + const result = utils.checkUndefined(undefined); + expect(result).toBe(true); + }); + + it('should return false for non-matching type', () => { + const result = utils.checkUndefined('123'); + expect(result).toBe(false); + }); + + it('should return true for matching type', () => { + const result = utils.checkPlainObject({ name: 'Jack' }); + expect(result).toBe(true); + }); + + it('should return true for matching type', () => { + const result = utils.checkDate(new Date()); + expect(result).toBe(true); + }); + + it('should return false for non-matching type', () => { + const result = utils.checkDate('2023-03-30'); + expect(result).toBe(false); + }); + + it('should return true for matching type', () => { + const result = utils.checkDate(new Date()); + expect(result).toBe(true); + }); + + it('should return false for non-matching type', () => { + const result = utils.checkDate('2023-03-30'); + expect(result).toBe(false); + }); + + it('should return true for matching type', () => { + const data = 'Hello, world!'; + const blob = new Blob([data], { type: 'text/plain' }); + const result = utils.checkBlob(blob); + expect(result).toBe(true); + }); + + it('should return false for non-matching type', () => { + const result = utils.checkFile('test.txt'); + expect(result).toBe(false); + }); +}); diff --git a/packages/horizon-request/tests/unitTest/utils/commonUtils/extendObject.test.ts b/packages/horizon-request/tests/unitTest/utils/commonUtils/extendObject.test.ts new file mode 100644 index 00000000..71add459 --- /dev/null +++ b/packages/horizon-request/tests/unitTest/utils/commonUtils/extendObject.test.ts @@ -0,0 +1,31 @@ +import utils from '../../../../src/utils/commonUtils/utils'; + +describe('extendObject function', () => { + it('should extend an object with another object', () => { + const target = { a: 1, b: 2 }; + const source = { b: 3, c: 4 }; + const result = utils.extendObject(target, source); + expect(result).toEqual({ a: 1, b: 3, c: 4 }); + expect(target).toEqual({ a: 1, b: 3, c: 4 }); // Target 也会被修改 + }); + + it('should bind functions to a given context if "thisArg" option is provided', () => { + const target = {}; + const source = { + sayHi() { + return `Hi, ${this.name}!`; + }, + name: 'John' + }; + const thisArg = { name: 'Sarah' }; + const result = utils.extendObject(target, source, thisArg); + expect(result.sayHi()).toBe('Hi, Sarah!'); + }); + + it('should include all properties of the source object if "includeAll" option is set to true', () => { + const target = { a: 1 }; + const source = { 'b': 2 }; + const result = utils.extendObject(target, source, undefined, { includeAll: true }); + expect(result).not.toEqual({ a: 1, 'b': 2 }); + }); +}); diff --git a/packages/horizon-request/tests/unitTest/utils/commonUtils/flattenObject.test.ts b/packages/horizon-request/tests/unitTest/utils/commonUtils/flattenObject.test.ts new file mode 100644 index 00000000..11670bcc --- /dev/null +++ b/packages/horizon-request/tests/unitTest/utils/commonUtils/flattenObject.test.ts @@ -0,0 +1,30 @@ +import utils from '../../../../src/utils/commonUtils/utils'; + +describe('flattenObject function', () => { + it('should return an empty object if the source object is null or undefined', () => { + expect(utils.flattenObject(null)).toEqual({}); + expect(utils.flattenObject(undefined)).toEqual({}); + }); + + it('should flatten a simple object', () => { + const sourceObj = { a: 1, b: 'hello', c: true }; + const destObj = utils.flattenObject(sourceObj); + expect(destObj).toEqual({ a: 1, b: 'hello', c: true }); + }); + + it('should flatten an object with a prototype', () => { + const parentObj = { a: 1 }; + const sourceObj = Object.create(parentObj); + sourceObj.b = 'hello'; + sourceObj.c = true; + const destObj = utils.flattenObject(sourceObj); + expect(destObj).toEqual({ a: 1, b: 'hello', c: true }); + }); + + it('should filter out properties based on a property filter function', () => { + const sourceObj = { a: 1, b: 'hello', c: true }; + const propFilter = (prop: string | symbol) => prop !== 'b'; + const destObj = utils.flattenObject(sourceObj, undefined, undefined, propFilter); + expect(destObj).toEqual({ a: 1, c: true }); + }); +}); diff --git a/packages/horizon-request/tests/unitTest/utils/commonUtils/forEach.test.ts b/packages/horizon-request/tests/unitTest/utils/commonUtils/forEach.test.ts new file mode 100644 index 00000000..4340c9a6 --- /dev/null +++ b/packages/horizon-request/tests/unitTest/utils/commonUtils/forEach.test.ts @@ -0,0 +1,41 @@ +import utils from '../../../../src/utils/commonUtils/utils'; + +describe('forEach function', () => { + it('should do nothing when input is null or undefined', () => { + const func = jest.fn(); + utils.forEach(null, func); + utils.forEach(undefined, func); + expect(func).not.toHaveBeenCalled(); + }); + + it('should iterate over array and call function with value, index and array', () => { + const arr = [1, 2, 3]; + const func = jest.fn(); + utils.forEach(arr, func); + expect(func).toHaveBeenCalledTimes(3); + expect(func).toHaveBeenCalledWith(1, 0, arr); + expect(func).toHaveBeenCalledWith(2, 1, arr); + expect(func).toHaveBeenCalledWith(3, 2, arr); + }); + + it('should iterate over object and call function with value, key and object', () => { + const obj = { a: 1, b: 2, c: 3 }; + const func = jest.fn(); + utils.forEach(obj, func); + expect(func).toHaveBeenCalledTimes(3); + expect(func).toHaveBeenCalledWith(1, 'a', obj); + expect(func).toHaveBeenCalledWith(2, 'b', obj); + expect(func).toHaveBeenCalledWith(3, 'c', obj); + }); + + it('should include all properties when options.includeAll is true', () => { + const obj = Object.create({ c: 3 }); + obj.a = 1; + obj.b = 2; + const func = jest.fn(); + utils.forEach(obj, func, { includeAll: true }); + expect(func).toHaveBeenCalledWith(1, 'a', obj); + expect(func).toHaveBeenCalledWith(2, 'b', obj); + expect(func).toHaveBeenCalledWith(3, 'c', obj); + }); +}); diff --git a/packages/horizon-request/tests/unitTest/utils/commonUtils/forEachEntry.test.ts b/packages/horizon-request/tests/unitTest/utils/commonUtils/forEachEntry.test.ts new file mode 100644 index 00000000..ffd3ef3c --- /dev/null +++ b/packages/horizon-request/tests/unitTest/utils/commonUtils/forEachEntry.test.ts @@ -0,0 +1,34 @@ +import utils from '../../../../src/utils/commonUtils/utils'; + +describe('forEachEntry function', () => { + const callback = jest.fn(); + it('should call the callback function for each entry in a plain object', () => { + const obj = { a: 1, b: 2, c: 3 }; + utils.forEachEntry(obj, callback); + expect(callback).toHaveBeenCalledTimes(3); + expect(callback).toHaveBeenCalledWith('a', 1); + expect(callback).toHaveBeenCalledWith('b', 2); + expect(callback).toHaveBeenCalledWith('c', 3); + }); + + it('should call the callback function for each entry in a Map object', () => { + const obj = new Map([['a', 1], ['b', 2], ['c', 3]]); + utils.forEachEntry(obj, callback); + expect(callback).toHaveBeenCalledTimes(3); + expect(callback).toHaveBeenCalledWith('a', 1); + expect(callback).toHaveBeenCalledWith('b', 2); + expect(callback).toHaveBeenCalledWith('c', 3); + }); + + it('should not call the callback function for non-enumerable properties', () => { + const obj = Object.create({}, { + a: { value: 1, enumerable: true }, + b: { value: 2, enumerable: false }, + c: { value: 3, enumerable: true } + }); + utils.forEachEntry(obj, callback); + expect(callback).toHaveBeenCalledTimes(2); + expect(callback).toHaveBeenCalledWith('a', 1); + expect(callback).toHaveBeenCalledWith('c', 3); + }); +}); diff --git a/packages/horizon-request/tests/unitTest/utils/commonUtils/getNormalizedValue.test.ts b/packages/horizon-request/tests/unitTest/utils/commonUtils/getNormalizedValue.test.ts new file mode 100644 index 00000000..eac93f83 --- /dev/null +++ b/packages/horizon-request/tests/unitTest/utils/commonUtils/getNormalizedValue.test.ts @@ -0,0 +1,20 @@ +import utils from '../../../../src/utils/commonUtils/utils'; + +describe('getNormalizedValue function', () => { + it('should return the same value if it is false or null', () => { + expect(utils.getNormalizedValue(false)).toBe(false); + expect(utils.getNormalizedValue(null)).toBe(null); + }); + + it('should convert the value to string if it is not false or null', () => { + expect(utils.getNormalizedValue('test')).toBe('test'); + expect(utils.getNormalizedValue(123)).toBe('123'); + expect(utils.getNormalizedValue(true)).toBe('true'); + }); + + it('should recursively normalize array values', () => { + expect(utils.getNormalizedValue(['foo', 'bar', 123])).toEqual(['foo', 'bar', '123']); + expect(utils.getNormalizedValue(['test', false, null])).toEqual(['test', false, null]); + expect(utils.getNormalizedValue(['one', ['two', 'three']])).toEqual(['one', ['two', 'three']]); + }); +}); diff --git a/packages/horizon-request/tests/unitTest/utils/commonUtils/getObjectByArray.test.ts b/packages/horizon-request/tests/unitTest/utils/commonUtils/getObjectByArray.test.ts new file mode 100644 index 00000000..4e48fb08 --- /dev/null +++ b/packages/horizon-request/tests/unitTest/utils/commonUtils/getObjectByArray.test.ts @@ -0,0 +1,32 @@ +import utils from '../../../../src/utils/commonUtils/utils'; + +describe('getObjectByArray function', () => { + it('should convert array to object with correct key-value pairs', () => { + const arr = ['a', 'b', 'c']; + const expected = { + '0': 'a', + '1': 'b', + '2': 'c' + }; + const result = utils.getObjectByArray(arr); + expect(result).toEqual(expected); + }); + + it('should return an empty object if the input array is empty', () => { + const arr: any[] = []; + const expected = {}; + const result = utils.getObjectByArray(arr); + expect(result).toEqual(expected); + }); + + it('should handle arrays with non-string elements', () => { + const arr = [1, true, { key: 'value' }]; + const expected = { + '0': 1, + '1': true, + '2': { key: 'value' } + }; + const result = utils.getObjectByArray(arr); + expect(result).toEqual(expected); + }); +}); diff --git a/packages/horizon-request/tests/unitTest/utils/commonUtils/getObjectKey.test.ts b/packages/horizon-request/tests/unitTest/utils/commonUtils/getObjectKey.test.ts new file mode 100644 index 00000000..2f27ec80 --- /dev/null +++ b/packages/horizon-request/tests/unitTest/utils/commonUtils/getObjectKey.test.ts @@ -0,0 +1,23 @@ +import utils from '../../../../src/utils/commonUtils/utils'; + +describe('getObjectKey function', () => { + it('should return null if object is empty', () => { + const obj = {}; + const result = utils.getObjectKey(obj, 'foo'); + expect(result).toBeNull(); + }); + + it('should return null if key is not found', () => { + const obj = { a: 1, b: 2 }; + const result = utils.getObjectKey(obj, 'c'); + expect(result).toBeNull(); + }); + + it('should return matching key in case-insensitive manner', () => { + const obj = { a: 1, b: 2, c: 3 }; + const result1 = utils.getObjectKey(obj, 'B'); + const result2 = utils.getObjectKey(obj, 'C'); + expect(result1).toBe('b'); + expect(result2).toBe('c'); + }); +}); diff --git a/packages/horizon-request/tests/unitTest/utils/commonUtils/getType.test.ts b/packages/horizon-request/tests/unitTest/utils/commonUtils/getType.test.ts new file mode 100644 index 00000000..1ce13e95 --- /dev/null +++ b/packages/horizon-request/tests/unitTest/utils/commonUtils/getType.test.ts @@ -0,0 +1,38 @@ +import utils from '../../../../src/utils/commonUtils/utils'; + +describe('getType function', () => { + it('should return type "undefined" for undefined value', () => { + const result = utils.getType(undefined); + expect(result).toBe('undefined'); + }); + + it('should return type "null" for null value', () => { + const result = utils.getType(null); + expect(result).toBe('null'); + }); + + it('should return type "string" for string value', () => { + const result = utils.getType('hello'); + expect(result).toBe('string'); + }); + + it('should return type "number" for number value', () => { + const result = utils.getType(123); + expect(result).toBe('number'); + }); + + it('should return type "boolean" for boolean value', () => { + const result = utils.getType(true); + expect(result).toBe('boolean'); + }); + + it('should return type "array" for array value', () => { + const result = utils.getType([1, 2, 3]); + expect(result).toBe('array'); + }); + + it('should return type "object" for object value', () => { + const result = utils.getType({ name: 'Alice' }); + expect(result).toBe('object'); + }); +}); diff --git a/packages/horizon-request/tests/unitTest/utils/commonUtils/objectToQueryString.test.ts b/packages/horizon-request/tests/unitTest/utils/commonUtils/objectToQueryString.test.ts new file mode 100644 index 00000000..0f2db507 --- /dev/null +++ b/packages/horizon-request/tests/unitTest/utils/commonUtils/objectToQueryString.test.ts @@ -0,0 +1,45 @@ +import utils from '../../../../src/utils/commonUtils/utils'; + +describe('objectToQueryString function', () => { + it('should return empty string if object is empty', () => { + const input = {}; + const expectedResult = ''; + const result = utils.objectToQueryString(input); + expect(result).toBe(expectedResult); + }); + + it('should return a query string with one parameter', () => { + const input = { key: 'value' }; + const expectedResult = 'key=value'; + const result = utils.objectToQueryString(input); + expect(result).toBe(expectedResult); + }); + + it('should return a query string with multiple parameters', () => { + const input = { key1: 'value1', key2: 'value2' }; + const expectedResult = 'key1=value1&key2=value2'; + const result = utils.objectToQueryString(input); + expect(result).toBe(expectedResult); + }); + + it('should encode keys and values', () => { + const input = { 'key with spaces': 'value with spaces' }; + const expectedResult = 'key%20with%20spaces=value%20with%20spaces'; + const result = utils.objectToQueryString(input); + expect(result).toBe(expectedResult); + }); + + it('should handle values of different types', () => { + const input = { + key1: 'string', + key2: 42, + key3: true, + key4: [1, 2, 3], + key5: { a: 'b' }, + }; + const expectedResult = + 'key1=string&key2=42&key3=true&key4=1%2C2%2C3&key5=%5Bobject%20Object%5D'; + const result = utils.objectToQueryString(input); + expect(result).toBe(expectedResult); + }); +}); diff --git a/packages/horizon-request/tests/unitTest/utils/commonUtils/stringifySafely.test.ts b/packages/horizon-request/tests/unitTest/utils/commonUtils/stringifySafely.test.ts new file mode 100644 index 00000000..c9254fdc --- /dev/null +++ b/packages/horizon-request/tests/unitTest/utils/commonUtils/stringifySafely.test.ts @@ -0,0 +1,33 @@ +import utils from '../../../../src/utils/commonUtils/utils'; + +describe('stringifySafely function', () => { + it('should return the input string if it is a valid JSON string', () => { + const input = '{"a": 1, "b": 2}'; + const result = utils.stringifySafely(input); + expect(result).toBe(input.trim()); + }); + + it('should call the parser function if it is provided', () => { + const input = '{"a": 1, "b": 2}'; + const parser = jest.fn().mockReturnValue({ a: 1, b: 2 }); + const result = utils.stringifySafely(input, parser); + expect(result).toBe(input.trim()); + expect(parser).toHaveBeenCalledWith(input); + }); + + it('should throw an error if the parser function throws an error', () => { + const input = '{"a": 1, "b": 2}'; + const parser = jest.fn().mockImplementation(() => { + throw new Error('Invalid JSON'); + }); + expect(() => utils.stringifySafely(input, parser)).toThrow('Invalid JSON'); + }); + + it('should use the encoder function to stringify the input', () => { + const input = { a: 1, b: 2 }; + const encoder = jest.fn().mockReturnValue('{"a":1,"b":2}'); + const result = utils.stringifySafely(input, undefined, encoder); + expect(result).toBe('{"a":1,"b":2}'); + expect(encoder).toHaveBeenCalledWith(input); + }); +}); diff --git a/packages/horizon-request/tests/unitTest/utils/commonUtils/toBooleanObject.test.ts b/packages/horizon-request/tests/unitTest/utils/commonUtils/toBooleanObject.test.ts new file mode 100644 index 00000000..865276a4 --- /dev/null +++ b/packages/horizon-request/tests/unitTest/utils/commonUtils/toBooleanObject.test.ts @@ -0,0 +1,45 @@ +import utils from '../../../../src/utils/commonUtils/utils'; + +describe('toBooleanObject function', () => { + it('should return an object with boolean properties', () => { + const result = utils.toBooleanObject('foo,bar,baz', ','); + expect(result).toEqual({ + 'foo': true, + 'bar': true, + 'baz': true, + }); + }); + + it('should handle an array of strings as input', () => { + const result = utils.toBooleanObject(['foo', 'bar', 'baz']); + expect(result).toEqual({ + 'foo': true, + 'bar': true, + 'baz': true, + }); + }); + + it('should return an empty object for empty input', () => { + const result = utils.toBooleanObject(''); + expect(result).toEqual({}); + }); + + it('should handle custom delimiter', () => { + const result = utils.toBooleanObject('foo|bar|baz', '|'); + expect(result).toEqual({ + 'foo': true, + 'bar': true, + 'baz': true, + }); + }); + + it('should handle spaces in input', () => { + const result = utils.toBooleanObject('foo, bar, baz', ','); + expect(result).toEqual({ + 'foo': true, + 'bar': true, + 'baz': true, + }); + }); +}); + diff --git a/packages/horizon-request/tests/unitTest/utils/commonUtils/toJSONSafe.test.ts b/packages/horizon-request/tests/unitTest/utils/commonUtils/toJSONSafe.test.ts new file mode 100644 index 00000000..65b14fdd --- /dev/null +++ b/packages/horizon-request/tests/unitTest/utils/commonUtils/toJSONSafe.test.ts @@ -0,0 +1,26 @@ +import utils from '../../../../src/utils/commonUtils/utils'; + +describe('toJSONSafe function', () => { + it('should return a clone of the object', () => { + const input = { a: 1, b: 2, c: [3, 4, 5], d: { e: 6 } }; + const result = utils.toJSONSafe(input); + expect(result).toEqual(input); + expect(result).not.toBe(input); + expect(result?.c).not.toBe(input.c); + expect(result?.d).not.toBe(input.d); + }); + + it('should handle toJSON method', () => { + const input = { a: 1, toJSON: () => ({ b: 2 }) }; + const result = utils.toJSONSafe(input); + expect(result).toEqual({ b: 2 }); + }); + + it('should handle arrays', () => { + const input = [1, 2, { a: 3 }, [4, 5]]; + const result = utils.toJSONSafe(input); + expect(result).toEqual([1, 2, { a: 3 }, [4, 5]]); + expect(result?.[2]).not.toBe(input[2]); + expect(result?.[3]).not.toBe(input[3]); + }); +}); diff --git a/packages/horizon-request/tests/unitTest/utils/configUtils/deepMerge.test.ts b/packages/horizon-request/tests/unitTest/utils/configUtils/deepMerge.test.ts new file mode 100644 index 00000000..4892ccbc --- /dev/null +++ b/packages/horizon-request/tests/unitTest/utils/configUtils/deepMerge.test.ts @@ -0,0 +1,26 @@ +import deepMerge from '../../../../src/utils/configUtils/deepMerge'; + +describe('deepMerge function', () => { + it('should return an empty object if no arguments are passed', () => { + expect(deepMerge()).toEqual({}); + }); + + it('should merge two objects', () => { + const obj1 = { a: 1, b: { c: 2 } }; + const obj2 = { b: { d: 3 }, e: 4 }; + expect(deepMerge(obj1, obj2)).toEqual({ a: 1, b: { c: 2, d: 3 }, e: 4 }); + }); + + it('should merge three or more objects', () => { + const obj1 = { a: 1, b: { c: 2 } }; + const obj2 = { b: { d: 3 }, e: 4 }; + const obj3 = { f: 5 }; + expect(deepMerge(obj1, obj2, obj3)).toEqual({ a: 1, b: { c: 2, d: 3 }, e: 4, f: 5 }); + }); + + it('should merge objects which later overlap', () => { + const obj1 = { a: 1, b: { c: 2 } }; + const obj2 = { b: { c: 1, d: 3 }, e: 4 }; + expect(deepMerge(obj1, obj2)).toEqual({ a: 1, b: { c: 1, d: 3 }, e: 4 }); + }); +}); diff --git a/packages/horizon-request/tests/unitTest/utils/configUtils/getMergedConfig.test.ts b/packages/horizon-request/tests/unitTest/utils/configUtils/getMergedConfig.test.ts new file mode 100644 index 00000000..e6062598 --- /dev/null +++ b/packages/horizon-request/tests/unitTest/utils/configUtils/getMergedConfig.test.ts @@ -0,0 +1,51 @@ +import getMergedConfig from '../../../../src/utils/configUtils/getMergedConfig'; + +describe('getMergedConfig function', () => { + it('should merge two configs correctly', () => { + const config1 = { + baseURL: 'https://example.com/api', + headers: { 'Content-Type': 'application/json' }, + timeout: 5000, + }; + + const config2 = { + method: 'POST', + data: { name: 'John', age: 25 }, + headers: { 'Authorization': 'Bearer token' }, + responseType: 'json', + }; + + const mergedConfig = getMergedConfig(config1, config2); + + expect(mergedConfig).toEqual({ + baseURL: 'https://example.com/api', + method: 'POST', + data: { name: 'John', age: 25 }, + headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer token' }, + timeout: 5000, + responseType: 'json', + }); + }); + + it('should handle missing or undefined config values', () => { + const config1 = { + baseURL: 'https://example.com/api', + headers: { 'Content-Type': 'application/json' }, + }; + + const config2 = { + method: 'POST', + data: { name: 'John', age: 25 }, + headers: undefined, + }; + + const mergedConfig = getMergedConfig(config1, config2); + + expect(mergedConfig).toEqual({ + baseURL: 'https://example.com/api', + method: 'POST', + data: { name: 'John', age: 25 }, + headers: { 'Content-Type': 'application/json' }, + }); + }); +}); diff --git a/packages/horizon-request/tests/unitTest/utils/dataUtils/getFormData.test.ts b/packages/horizon-request/tests/unitTest/utils/dataUtils/getFormData.test.ts new file mode 100644 index 00000000..c95c216c --- /dev/null +++ b/packages/horizon-request/tests/unitTest/utils/dataUtils/getFormData.test.ts @@ -0,0 +1,52 @@ +import getFormData from '../../../../src/utils/dataUtils/getFormData'; + +describe('getFormData function', () => { + it('should convert object to FormData', () => { + const obj = { + name: 'John', + age: 30, + hobbies: ['Reading', 'Gaming'], + }; + + const formData = getFormData(obj); + + expect(formData.get('name')).toBe('John'); + expect(formData.get('age')).toBe('30'); + expect(formData.getAll('hobbies')).toEqual(['Reading', 'Gaming']); + }); + + it('should append to existing FormData', () => { + const existingFormData = new FormData(); + existingFormData.append('existing', 'value'); + + const obj = { + name: 'John', + age: 30, + }; + + const formData = getFormData(obj, existingFormData); + + expect(formData.get('existing')).toBe('value'); + expect(formData.get('name')).toBe('John'); + expect(formData.get('age')).toBe('30'); + }); + + it('should handle empty object', () => { + const obj = {}; + + const formData = getFormData(obj); + + expect(formData.get('name')).toBeNull(); + expect(formData.get('age')).toBeNull(); + }); + + it('should convert array values to string', () => { + const obj = { + items: [1, 2, 3], + }; + + const formData = getFormData(obj); + + expect(formData.getAll('items')).toEqual(['1', '2', '3']); + }); +}); diff --git a/packages/horizon-request/tests/unitTest/utils/dataUtils/getJSONByFormData.test.ts b/packages/horizon-request/tests/unitTest/utils/dataUtils/getJSONByFormData.test.ts new file mode 100644 index 00000000..0f95a724 --- /dev/null +++ b/packages/horizon-request/tests/unitTest/utils/dataUtils/getJSONByFormData.test.ts @@ -0,0 +1,31 @@ +import getJSONByFormData from '../../../../src/utils/dataUtils/getJSONByFormData'; + +describe('getJSONByFormData function', () => { + it('should convert FormData to JSON object', () => { + const formData = new FormData(); + formData.append('name', 'John'); + formData.append('age', '30'); + + const result = getJSONByFormData(formData); + + expect(result).toEqual({ + name: 'John', + age: '30', + }); + }); + + it('should return null if FormData or entries() is not available', () => { + const invalidFormData = {} as FormData; + const result = getJSONByFormData(invalidFormData); + + expect(result).toBeNull(); + }); + + it('should handle empty FormData', () => { + const emptyFormData = new FormData(); + + const result = getJSONByFormData(emptyFormData); + + expect(result).toEqual({}); + }); +}); \ No newline at end of file diff --git a/packages/horizon-request/tests/unitTest/utils/dataUtils/parsePath.test.ts b/packages/horizon-request/tests/unitTest/utils/dataUtils/parsePath.test.ts new file mode 100644 index 00000000..1fa20538 --- /dev/null +++ b/packages/horizon-request/tests/unitTest/utils/dataUtils/parsePath.test.ts @@ -0,0 +1,10 @@ +import { parsePath } from '../../../../src/utils/dataUtils/getJSONByFormData'; + +describe('parsePath function', () => { + it('should parse path correctly', () => { + expect(parsePath('users[0].name')).toEqual(['users', '0', 'name']); + expect(parsePath('books[2][title]')).toEqual(['books', '2', 'title']); + expect(parsePath('')).toEqual([]); + expect(parsePath('property')).toEqual(['property']); + }); +}); diff --git a/packages/horizon-request/tests/unitTest/utils/headerUtils/checkHeaderName.test.ts b/packages/horizon-request/tests/unitTest/utils/headerUtils/checkHeaderName.test.ts new file mode 100644 index 00000000..dc5f6b46 --- /dev/null +++ b/packages/horizon-request/tests/unitTest/utils/headerUtils/checkHeaderName.test.ts @@ -0,0 +1,21 @@ +import checkHeaderName from '../../../../src/utils/headerUtils/checkHeaderName'; + +describe('checkHeaderName', () => { + it('should return true for valid header name', () => { + const validHeaderName = 'Content-Type'; + const result = checkHeaderName(validHeaderName); + expect(result).toBe(true); + }); + + it('should return false for invalid header name', () => { + const invalidHeaderName = 'Content-Type!'; + const result = checkHeaderName(invalidHeaderName); + expect(result).toBe(false); + }); + + it('should return false for empty string', () => { + const emptyString = ''; + const result = checkHeaderName(emptyString); + expect(result).toBe(false); + }); +}); diff --git a/packages/horizon-request/tests/unitTest/utils/headerUtils/convertRawHeaders.test.ts b/packages/horizon-request/tests/unitTest/utils/headerUtils/convertRawHeaders.test.ts new file mode 100644 index 00000000..7fe6331a --- /dev/null +++ b/packages/horizon-request/tests/unitTest/utils/headerUtils/convertRawHeaders.test.ts @@ -0,0 +1,46 @@ +import convertRawHeaders from '../../../../src/utils/headerUtils/convertRawHeaders'; + +describe('convertRawHeaders', () => { + it('should convert raw headers to a header map object', () => { + const rawHeaders = 'Content-Type: application/json\nAuthorization: Bearer token'; + const expectedHeaders = { + 'content-type': 'application/json', + 'authorization': 'Bearer token', + }; + const result = convertRawHeaders(rawHeaders); + expect(result).toEqual(expectedHeaders); + }); + + it('should handle multiple occurrences of Set-Cookie header', () => { + const rawHeaders = 'Set-Cookie: cookie1=value1\nSet-Cookie: cookie2=value2'; + const expectedHeaders = { + 'set-cookie': ['cookie1=value1', 'cookie2=value2'], + }; + const result = convertRawHeaders(rawHeaders); + expect(result).toEqual(expectedHeaders); + }); + + it('should handle empty raw headers', () => { + const rawHeaders = ''; + const expectedHeaders = {}; + const result = convertRawHeaders(rawHeaders); + expect(result).toEqual(expectedHeaders); + }); + + it('should handle raw headers with leading/trailing whitespaces', () => { + const rawHeaders = ' Content-Type: application/json\nAuthorization: Bearer token '; + const expectedHeaders = { + 'content-type': 'application/json', + 'authorization': 'Bearer token', + }; + const result = convertRawHeaders(rawHeaders); + expect(result).toEqual(expectedHeaders); + }); + + it('should handle raw headers with missing colon', () => { + const rawHeaders = 'Content-Type application/json'; + const expectedHeaders = {}; + const result = convertRawHeaders(rawHeaders); + expect(result).toEqual(expectedHeaders); + }); +}); diff --git a/packages/horizon-request/tests/unitTest/utils/headerUtils/deleteHeader.test.ts b/packages/horizon-request/tests/unitTest/utils/headerUtils/deleteHeader.test.ts new file mode 100644 index 00000000..5d2d5059 --- /dev/null +++ b/packages/horizon-request/tests/unitTest/utils/headerUtils/deleteHeader.test.ts @@ -0,0 +1,35 @@ +import deleteHeader from '../../../../src/utils/headerUtils/deleteHeader'; + +describe('deleteHeader function', () => { + it('should delete existing header property from the object', () => { + const obj = { 'content-type': 'application/json', 'authorization': 'Bearer token' }; + const headerToDelete = 'content-type'; + const result = deleteHeader.call(obj, headerToDelete); + expect(result).toBe(true); + expect(obj).toEqual({ 'authorization': 'Bearer token' }); + }); + + it('should return false if header property does not exist', () => { + const obj = { 'content-type': 'application/json', 'authorization': 'Bearer token' }; + const headerToDelete = 'x-custom-header'; + const result = deleteHeader.call(obj, headerToDelete); + expect(result).toBe(false); + expect(obj).toEqual({ 'content-type': 'application/json', 'authorization': 'Bearer token' }); + }); + + it('should return false if header parameter is empty', () => { + const obj = { 'content-type': 'application/json', 'authorization': 'Bearer token' }; + const headerToDelete = ''; + const result = deleteHeader.call(obj, headerToDelete); + expect(result).toBe(false); + expect(obj).toEqual({ 'content-type': 'application/json', 'authorization': 'Bearer token' }); + }); + + it('should delete header property with different case sensitivity', () => { + const obj = { 'Content-Type': 'application/json', 'Authorization': 'Bearer token' }; + const headerToDelete = 'content-type'; + const result = deleteHeader.call(obj, headerToDelete); + expect(result).toBe(true); + expect(obj).toEqual({ 'Authorization': 'Bearer token' }); + }); +}); diff --git a/packages/horizon-request/tests/unitTest/utils/headerUtils/parseKeyValuePairs.test.ts b/packages/horizon-request/tests/unitTest/utils/headerUtils/parseKeyValuePairs.test.ts new file mode 100644 index 00000000..f7efe925 --- /dev/null +++ b/packages/horizon-request/tests/unitTest/utils/headerUtils/parseKeyValuePairs.test.ts @@ -0,0 +1,65 @@ +import { parseKeyValuePairs } from '../../../../src/utils/headerUtils/processValueByParser'; + +describe('parseKeyValuePairs function', () => { + it('should parse key-value pairs separated by commas', () => { + const input = 'key1=value1, key2=value2, key3=value3'; + const expectedOutput = { + 'key1': 'value1', + 'key2': 'value2', + 'key3': 'value3', + }; + const result = parseKeyValuePairs(input); + expect(result).toEqual(expectedOutput); + }); + + it('should parse key-value pairs separated by semicolons', () => { + const input = 'key1=value1; key2=value2; key3=value3'; + const expectedOutput = { + 'key1': 'value1', + 'key2': 'value2', + 'key3': 'value3', + }; + const result = parseKeyValuePairs(input); + expect(result).toEqual(expectedOutput); + }); + + it('should parse key-value pairs with no spaces', () => { + const input = 'key1=value1,key2=value2,key3=value3'; + const expectedOutput = { + 'key1': 'value1', + 'key2': 'value2', + 'key3': 'value3', + }; + const result = parseKeyValuePairs(input); + expect(result).toEqual(expectedOutput); + }); + + it('should parse key-value pairs with leading/trailing spaces', () => { + const input = ' key1 = value1 , key2 = value2 , key3 = value3 '; + const expectedOutput = { + 'key1': 'value1', + 'key2': 'value2', + 'key3': 'value3', + }; + const result = parseKeyValuePairs(input); + expect(result).toEqual(expectedOutput); + }); + + it('should parse key-value pairs with spaces', () => { + const input = 'key1=value1, key2=value with spaces, key3=value3'; + const expectedOutput = { + 'key1': 'value1', + 'key2': 'value with spaces', + 'key3': 'value3', + }; + const result = parseKeyValuePairs(input); + expect(result).toEqual(expectedOutput); + }); + + it('should return an empty object for empty input', () => { + const input = ''; + const expectedOutput = {}; + const result = parseKeyValuePairs(input); + expect(result).toEqual(expectedOutput); + }); +}); diff --git a/packages/horizon-request/tests/unitTest/utils/headerUtils/processValueByParser.test.ts b/packages/horizon-request/tests/unitTest/utils/headerUtils/processValueByParser.test.ts new file mode 100644 index 00000000..3267956e --- /dev/null +++ b/packages/horizon-request/tests/unitTest/utils/headerUtils/processValueByParser.test.ts @@ -0,0 +1,49 @@ +import processValueByParser from '../../../../src/utils/headerUtils/processValueByParser'; + +describe('processValueByParser function', () => { + it('should return value when parser is not provided', () => { + const key = 'Content-Type'; + const value = 'application/json'; + const result = processValueByParser(key, value); + expect(result).toBe(value); + }); + + it('should parse key-value pairs when parser is true', () => { + const key = 'Cookie'; + const value = 'key1=value1; key2=value2; key3=value3'; + const result = processValueByParser(key, value, true); + const expectedOutput = { + 'key1': 'value1', + 'key2': 'value2', + 'key3': 'value3', + }; + expect(result).toEqual(expectedOutput); + }); + + it('should execute custom parser function when parser is a function', () => { + const key = 'Authorization'; + const value = 'Bearer token'; + const customParser = (val: string, key: string) => `${key} ${val}`; + const result = processValueByParser(key, value, customParser); + const expectedOutput = 'Authorization Bearer token'; + expect(result).toBe(expectedOutput); + }); + + it('should execute regular expression match when parser is a regular expression', () => { + const key = 'User-Agent'; + const value = 'Mozilla/5.0'; + const parser = /Mozilla\/(\d+\.\d+)/; + const result = processValueByParser(key, value, parser); + const expectedOutput = ['Mozilla/5.0', '5.0']; + expect(result).toEqual(expectedOutput); + }); + + it('should throw a TypeError for an incorrect parser', () => { + const key = 'Some-Header'; + const value = 'Some Value'; + const incorrectParser = 'invalid' as any; + expect(() => { + processValueByParser(key, value, incorrectParser); + }).toThrow(TypeError); + }); +}); diff --git a/packages/horizon-request/tsconfig.json b/packages/horizon-request/tsconfig.json new file mode 100644 index 00000000..eca55465 --- /dev/null +++ b/packages/horizon-request/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "outDir": "./dist", + "module": "ES2015", + "target": "ES5", + "allowJs": true, + "strict": true, + "lib": ["dom", "esnext", "ES2015", "ES2016", "ES2017", "ES2018", "ES2019", "ES2020"], + "suppressImplicitAnyIndexErrors": true, + "esModuleInterop": true, + "declaration": true, + "moduleResolution": "node", + "sourceMap": true, + "downlevelIteration": true + }, + "include": [ + "./src/**/*", + "./examples/**/*" + ] +} diff --git a/packages/horizon-request/webpack.config.js b/packages/horizon-request/webpack.config.js new file mode 100644 index 00000000..1b92c4e7 --- /dev/null +++ b/packages/horizon-request/webpack.config.js @@ -0,0 +1,44 @@ +// 引入路径包 +const path = require('path'); + +// webpack配置信息 +module.exports = { + // 指定入口文件 + entry: './src/horizonRequest.ts', + + // 指定打包文件信息 + output: { + // 指定打包文件目录 + path: path.resolve(__dirname, 'dist'), + library: 'myLibrary', + libraryTarget: 'umd', + filename: 'horizonRequest.js', + }, + + // 指定打包时使用的模块 + module: { + // 指定加载规则 + rules: [ + { + // 指定规则生效的文件 + test: /\.ts$/, + use: ['babel-loader', 'ts-loader'], + + // 排除不需要生效的文件 + exclude: /node_modules/, + }, + { + test: /\.js$/, + use: ['babel-loader'], + + exclude: /node_modules/, + }, + ], + }, + + mode: 'development', + + resolve: { + extensions: ['.js', '.ts'], + }, +}; diff --git a/packages/horizon-request/webpack.useHR.config.js b/packages/horizon-request/webpack.useHR.config.js new file mode 100644 index 00000000..47012113 --- /dev/null +++ b/packages/horizon-request/webpack.useHR.config.js @@ -0,0 +1,49 @@ +const path = require('path'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); +const {resolve} = require("path"); + +module.exports = { + entry: './examples/useHR/index.jsx', // 入口文件 + output: { + path: path.resolve(__dirname, 'dist'), // 输出目录 + filename: 'bundle.js' // 输出文件名 + }, + module: { + rules: [ + { + test: /\.([t|j]s)x?$/i, + use: { + loader: 'babel-loader', + options: { + presets: [ + '@babel/preset-env', + [ + "@babel/preset-react", + { + "runtime": "automatic", + "importSource": "@cloudsop/horizon" + } + ], + '@babel/preset-typescript' + ], + }, + }, + } + ] + }, + plugins: [ + new HtmlWebpackPlugin({ + template: resolve(__dirname, './examples/useHR/index.html'), + }), + ], + resolve: { + extensions: ['.tsx', '.jsx', '.ts', '.js', '.json'], + }, + devServer: { + https: false, + host: 'localhost', + port: '8888', + open: true, + hot: true, + } +};