diff --git a/package.json b/package.json index ea852e5..6539c5a 100644 --- a/package.json +++ b/package.json @@ -58,34 +58,33 @@ "prepack": "pnpm exec ./scripts/pre-pack.sh" }, "devDependencies": { - "@commitlint/cli": "19.6.0", + "@commitlint/cli": "19.6.1", "@commitlint/config-conventional": "19.6.0", "@commitlint/types": "19.5.0", - "@rollup/plugin-commonjs": "28.0.1", - "@rollup/plugin-node-resolve": "15.3.0", + "@rollup/plugin-commonjs": "28.0.2", + "@rollup/plugin-node-resolve": "16.0.0", "@rollup/plugin-replace": "6.0.2", "@rollup/plugin-terser": "0.4.4", - "@rollup/plugin-typescript": "12.1.1", + "@rollup/plugin-typescript": "12.1.2", "@strapi/eslint-config": "0.2.1", "@types/debug": "4.1.12", "@types/jest": "29.5.14", - "@typescript-eslint/eslint-plugin": "8.15.0", - "@typescript-eslint/parser": "8.15.0", + "@typescript-eslint/eslint-plugin": "8.21.0", + "@typescript-eslint/parser": "8.21.0", "eslint": "8.57.1", "eslint-plugin-import": "2.31.0", "husky": "9.1.7", "jest": "29.7.0", - "lint-staged": "15.2.10", - "prettier": "3.3.3", + "lint-staged": "15.4.1", + "prettier": "3.4.2", "rimraf": "6.0.1", - "rollup": "4.27.3", + "rollup": "4.31.0", "ts-jest": "29.2.5", - "typescript": "5.6.3" + "typescript": "5.7.3" }, "packageManager": "pnpm@9.1.0", "engines": { - "node": ">=18.0.0 <=22.x.x", - "npm": ">=6.0.0" + "node": ">=20.18.2 <=22.x.x" }, "dependencies": { "debug": "4.4.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e2bbf1..b778e4e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: version: 4.4.0 devDependencies: '@commitlint/cli': - specifier: 19.6.0 - version: 19.6.0(@types/node@22.10.2)(typescript@5.6.3) + specifier: 19.6.1 + version: 19.6.1(@types/node@22.10.2)(typescript@5.7.3) '@commitlint/config-conventional': specifier: 19.6.0 version: 19.6.0 @@ -21,23 +21,23 @@ importers: specifier: 19.5.0 version: 19.5.0 '@rollup/plugin-commonjs': - specifier: 28.0.1 - version: 28.0.1(rollup@4.27.3) + specifier: 28.0.2 + version: 28.0.2(rollup@4.31.0) '@rollup/plugin-node-resolve': - specifier: 15.3.0 - version: 15.3.0(rollup@4.27.3) + specifier: 16.0.0 + version: 16.0.0(rollup@4.31.0) '@rollup/plugin-replace': specifier: 6.0.2 - version: 6.0.2(rollup@4.27.3) + version: 6.0.2(rollup@4.31.0) '@rollup/plugin-terser': specifier: 0.4.4 - version: 0.4.4(rollup@4.27.3) + version: 0.4.4(rollup@4.31.0) '@rollup/plugin-typescript': - specifier: 12.1.1 - version: 12.1.1(rollup@4.27.3)(tslib@1.14.1)(typescript@5.6.3) + specifier: 12.1.2 + version: 12.1.2(rollup@4.31.0)(tslib@2.8.1)(typescript@5.7.3) '@strapi/eslint-config': specifier: 0.2.1 - version: 0.2.1(@babel/core@7.26.0)(prettier@3.3.3)(typescript@5.6.3) + version: 0.2.1(@babel/core@7.26.0)(prettier@3.4.2)(typescript@5.7.3) '@types/debug': specifier: 4.1.12 version: 4.1.12 @@ -45,17 +45,17 @@ importers: specifier: 29.5.14 version: 29.5.14 '@typescript-eslint/eslint-plugin': - specifier: 8.15.0 - version: 8.15.0(@typescript-eslint/parser@8.15.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1)(typescript@5.6.3) + specifier: 8.21.0 + version: 8.21.0(@typescript-eslint/parser@8.21.0(eslint@8.57.1)(typescript@5.7.3))(eslint@8.57.1)(typescript@5.7.3) '@typescript-eslint/parser': - specifier: 8.15.0 - version: 8.15.0(eslint@8.57.1)(typescript@5.6.3) + specifier: 8.21.0 + version: 8.21.0(eslint@8.57.1)(typescript@5.7.3) eslint: specifier: 8.57.1 version: 8.57.1 eslint-plugin-import: specifier: 2.31.0 - version: 2.31.0(@typescript-eslint/parser@8.15.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1) + version: 2.31.0(@typescript-eslint/parser@8.21.0(eslint@8.57.1)(typescript@5.7.3))(eslint@8.57.1) husky: specifier: 9.1.7 version: 9.1.7 @@ -63,23 +63,23 @@ importers: specifier: 29.7.0 version: 29.7.0(@types/node@22.10.2) lint-staged: - specifier: 15.2.10 - version: 15.2.10 + specifier: 15.4.1 + version: 15.4.1 prettier: - specifier: 3.3.3 - version: 3.3.3 + specifier: 3.4.2 + version: 3.4.2 rimraf: specifier: 6.0.1 version: 6.0.1 rollup: - specifier: 4.27.3 - version: 4.27.3 + specifier: 4.31.0 + version: 4.31.0 ts-jest: specifier: 29.2.5 - version: 29.2.5(@babel/core@7.26.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.0))(jest@29.7.0(@types/node@22.10.2))(typescript@5.6.3) + version: 29.2.5(@babel/core@7.26.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.0))(jest@29.7.0(@types/node@22.10.2))(typescript@5.7.3) typescript: - specifier: 5.6.3 - version: 5.6.3 + specifier: 5.7.3 + version: 5.7.3 packages: '@ampproject/remapping@2.3.0': @@ -369,10 +369,10 @@ packages: integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==, } - '@commitlint/cli@19.6.0': + '@commitlint/cli@19.6.1': resolution: { - integrity: sha512-v17BgGD9w5KnthaKxXnEg6KLq6DYiAxyiN44TpiRtqyW8NSq+Kx99mkEG8Qo6uu6cI5eMzMojW2muJxjmPnF8w==, + integrity: sha512-8hcyA6ZoHwWXC76BoC8qVOSr8xHy00LZhZpauiD0iO0VYbVhMnED0da85lTfIULxl7Lj4c6vZgF0Wu/ed1+jlQ==, } engines: { node: '>=v18' } hasBin: true @@ -758,10 +758,10 @@ packages: } engines: { node: '>=12.4.0' } - '@rollup/plugin-commonjs@28.0.1': + '@rollup/plugin-commonjs@28.0.2': resolution: { - integrity: sha512-+tNWdlWKbpB3WgBN7ijjYkq9X5uhjmcvyjEght4NmH5fAU++zfQzAJ6wumLS+dNcvwEZhKx2Z+skY8m7v0wGSA==, + integrity: sha512-BEFI2EDqzl+vA1rl97IDRZ61AIwGH093d9nz8+dThxJNH8oSoB7MjWvPCX3dkaK1/RCJ/1v/R1XB15FuSs0fQw==, } engines: { node: '>=16.0.0 || 14 >= 14.17' } peerDependencies: @@ -770,10 +770,10 @@ packages: rollup: optional: true - '@rollup/plugin-node-resolve@15.3.0': + '@rollup/plugin-node-resolve@16.0.0': resolution: { - integrity: sha512-9eO5McEICxMzJpDW9OnMYSv4Sta3hmt7VtBFz5zR9273suNOydOyq/FrGeGy+KsTRFm8w0SLVhzig2ILFT63Ag==, + integrity: sha512-0FPvAeVUT/zdWoO0jnb/V5BlBsUSNfkIOtFHzMO4H9MOklrmQFY6FduVHKucNb/aTFxvnGhj4MNj/T1oNdDfNg==, } engines: { node: '>=14.0.0' } peerDependencies: @@ -806,10 +806,10 @@ packages: rollup: optional: true - '@rollup/plugin-typescript@12.1.1': + '@rollup/plugin-typescript@12.1.2': resolution: { - integrity: sha512-t7O653DpfB5MbFrqPe/VcKFFkvRuFNp9qId3xq4Eth5xlyymzxNpye2z8Hrl0RIMuXTSr5GGcFpkdlMeacUiFQ==, + integrity: sha512-cdtSp154H5sv637uMr1a8OTWB0L1SWDSm1rDGiyfcGcvQ6cuTs4MDk2BVEBGysUWago4OJN4EQZqOTl/QY3Jgg==, } engines: { node: '>=14.0.0' } peerDependencies: @@ -834,146 +834,154 @@ packages: rollup: optional: true - '@rollup/rollup-android-arm-eabi@4.27.3': + '@rollup/rollup-android-arm-eabi@4.31.0': resolution: { - integrity: sha512-EzxVSkIvCFxUd4Mgm4xR9YXrcp976qVaHnqom/Tgm+vU79k4vV4eYTjmRvGfeoW8m9LVcsAy/lGjcgVegKEhLQ==, + integrity: sha512-9NrR4033uCbUBRgvLcBrJofa2KY9DzxL2UKZ1/4xA/mnTNyhZCWBuD8X3tPm1n4KxcgaraOYgrFKSgwjASfmlA==, } cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.27.3': + '@rollup/rollup-android-arm64@4.31.0': resolution: { - integrity: sha512-LJc5pDf1wjlt9o/Giaw9Ofl+k/vLUaYsE2zeQGH85giX2F+wn/Cg8b3c5CDP3qmVmeO5NzwVUzQQxwZvC2eQKw==, + integrity: sha512-iBbODqT86YBFHajxxF8ebj2hwKm1k8PTBQSojSt3d1FFt1gN+xf4CowE47iN0vOSdnd+5ierMHBbu/rHc7nq5g==, } cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.27.3': + '@rollup/rollup-darwin-arm64@4.31.0': resolution: { - integrity: sha512-OuRysZ1Mt7wpWJ+aYKblVbJWtVn3Cy52h8nLuNSzTqSesYw1EuN6wKp5NW/4eSre3mp12gqFRXOKTcN3AI3LqA==, + integrity: sha512-WHIZfXgVBX30SWuTMhlHPXTyN20AXrLH4TEeH/D0Bolvx9PjgZnn4H677PlSGvU6MKNsjCQJYczkpvBbrBnG6g==, } cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.27.3': + '@rollup/rollup-darwin-x64@4.31.0': resolution: { - integrity: sha512-xW//zjJMlJs2sOrCmXdB4d0uiilZsOdlGQIC/jjmMWT47lkLLoB1nsNhPUcnoqyi5YR6I4h+FjBpILxbEy8JRg==, + integrity: sha512-hrWL7uQacTEF8gdrQAqcDy9xllQ0w0zuL1wk1HV8wKGSGbKPVjVUv/DEwT2+Asabf8Dh/As+IvfdU+H8hhzrQQ==, } cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.27.3': + '@rollup/rollup-freebsd-arm64@4.31.0': resolution: { - integrity: sha512-58E0tIcwZ+12nK1WiLzHOD8I0d0kdrY/+o7yFVPRHuVGY3twBwzwDdTIBGRxLmyjciMYl1B/U515GJy+yn46qw==, + integrity: sha512-S2oCsZ4hJviG1QjPY1h6sVJLBI6ekBeAEssYKad1soRFv3SocsQCzX6cwnk6fID6UQQACTjeIMB+hyYrFacRew==, } cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.27.3': + '@rollup/rollup-freebsd-x64@4.31.0': resolution: { - integrity: sha512-78fohrpcVwTLxg1ZzBMlwEimoAJmY6B+5TsyAZ3Vok7YabRBUvjYTsRXPTjGEvv/mfgVBepbW28OlMEz4w8wGA==, + integrity: sha512-pCANqpynRS4Jirn4IKZH4tnm2+2CqCNLKD7gAdEjzdLGbH1iO0zouHz4mxqg0uEMpO030ejJ0aA6e1PJo2xrPA==, } cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.27.3': + '@rollup/rollup-linux-arm-gnueabihf@4.31.0': resolution: { - integrity: sha512-h2Ay79YFXyQi+QZKo3ISZDyKaVD7uUvukEHTOft7kh00WF9mxAaxZsNs3o/eukbeKuH35jBvQqrT61fzKfAB/Q==, + integrity: sha512-0O8ViX+QcBd3ZmGlcFTnYXZKGbFu09EhgD27tgTdGnkcYXLat4KIsBBQeKLR2xZDCXdIBAlWLkiXE1+rJpCxFw==, } cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.27.3': + '@rollup/rollup-linux-arm-musleabihf@4.31.0': resolution: { - integrity: sha512-Sv2GWmrJfRY57urktVLQ0VKZjNZGogVtASAgosDZ1aUB+ykPxSi3X1nWORL5Jk0sTIIwQiPH7iE3BMi9zGWfkg==, + integrity: sha512-w5IzG0wTVv7B0/SwDnMYmbr2uERQp999q8FMkKG1I+j8hpPX2BYFjWe69xbhbP6J9h2gId/7ogesl9hwblFwwg==, } cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.27.3': + '@rollup/rollup-linux-arm64-gnu@4.31.0': resolution: { - integrity: sha512-FPoJBLsPW2bDNWjSrwNuTPUt30VnfM8GPGRoLCYKZpPx0xiIEdFip3dH6CqgoT0RnoGXptaNziM0WlKgBc+OWQ==, + integrity: sha512-JyFFshbN5xwy6fulZ8B/8qOqENRmDdEkcIMF0Zz+RsfamEW+Zabl5jAb0IozP/8UKnJ7g2FtZZPEUIAlUSX8cA==, } cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.27.3': + '@rollup/rollup-linux-arm64-musl@4.31.0': resolution: { - integrity: sha512-TKxiOvBorYq4sUpA0JT+Fkh+l+G9DScnG5Dqx7wiiqVMiRSkzTclP35pE6eQQYjP4Gc8yEkJGea6rz4qyWhp3g==, + integrity: sha512-kpQXQ0UPFeMPmPYksiBL9WS/BDiQEjRGMfklVIsA0Sng347H8W2iexch+IEwaR7OVSKtr2ZFxggt11zVIlZ25g==, } cpu: [arm64] os: [linux] - '@rollup/rollup-linux-powerpc64le-gnu@4.27.3': + '@rollup/rollup-linux-loongarch64-gnu@4.31.0': + resolution: + { + integrity: sha512-pMlxLjt60iQTzt9iBb3jZphFIl55a70wexvo8p+vVFK+7ifTRookdoXX3bOsRdmfD+OKnMozKO6XM4zR0sHRrQ==, + } + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-powerpc64le-gnu@4.31.0': resolution: { - integrity: sha512-v2M/mPvVUKVOKITa0oCFksnQQ/TqGrT+yD0184/cWHIu0LoIuYHwox0Pm3ccXEz8cEQDLk6FPKd1CCm+PlsISw==, + integrity: sha512-D7TXT7I/uKEuWiRkEFbed1UUYZwcJDU4vZQdPTcepK7ecPhzKOYk4Er2YR4uHKme4qDeIh6N3XrLfpuM7vzRWQ==, } cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.27.3': + '@rollup/rollup-linux-riscv64-gnu@4.31.0': resolution: { - integrity: sha512-LdrI4Yocb1a/tFVkzmOE5WyYRgEBOyEhWYJe4gsDWDiwnjYKjNs7PS6SGlTDB7maOHF4kxevsuNBl2iOcj3b4A==, + integrity: sha512-wal2Tc8O5lMBtoePLBYRKj2CImUCJ4UNGJlLwspx7QApYny7K1cUYlzQ/4IGQBLmm+y0RS7dwc3TDO/pmcneTw==, } cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.27.3': + '@rollup/rollup-linux-s390x-gnu@4.31.0': resolution: { - integrity: sha512-d4wVu6SXij/jyiwPvI6C4KxdGzuZOvJ6y9VfrcleHTwo68fl8vZC5ZYHsCVPUi4tndCfMlFniWgwonQ5CUpQcA==, + integrity: sha512-O1o5EUI0+RRMkK9wiTVpk2tyzXdXefHtRTIjBbmFREmNMy7pFeYXCFGbhKFwISA3UOExlo5GGUuuj3oMKdK6JQ==, } cpu: [s390x] os: [linux] - '@rollup/rollup-linux-x64-gnu@4.27.3': + '@rollup/rollup-linux-x64-gnu@4.31.0': resolution: { - integrity: sha512-/6bn6pp1fsCGEY5n3yajmzZQAh+mW4QPItbiWxs69zskBzJuheb3tNynEjL+mKOsUSFK11X4LYF2BwwXnzWleA==, + integrity: sha512-zSoHl356vKnNxwOWnLd60ixHNPRBglxpv2g7q0Cd3Pmr561gf0HiAcUBRL3S1vPqRC17Zo2CX/9cPkqTIiai1g==, } cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-musl@4.27.3': + '@rollup/rollup-linux-x64-musl@4.31.0': resolution: { - integrity: sha512-nBXOfJds8OzUT1qUreT/en3eyOXd2EH5b0wr2bVB5999qHdGKkzGzIyKYaKj02lXk6wpN71ltLIaQpu58YFBoQ==, + integrity: sha512-ypB/HMtcSGhKUQNiFwqgdclWNRrAYDH8iMYH4etw/ZlGwiTVxBz2tDrGRrPlfZu6QjXwtd+C3Zib5pFqID97ZA==, } cpu: [x64] os: [linux] - '@rollup/rollup-win32-arm64-msvc@4.27.3': + '@rollup/rollup-win32-arm64-msvc@4.31.0': resolution: { - integrity: sha512-ogfbEVQgIZOz5WPWXF2HVb6En+kWzScuxJo/WdQTqEgeyGkaa2ui5sQav9Zkr7bnNCLK48uxmmK0TySm22eiuw==, + integrity: sha512-JuhN2xdI/m8Hr+aVO3vspO7OQfUFO6bKLIRTAy0U15vmWjnZDLrEgCZ2s6+scAYaQVpYSh9tZtRijApw9IXyMw==, } cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.27.3': + '@rollup/rollup-win32-ia32-msvc@4.31.0': resolution: { - integrity: sha512-ecE36ZBMLINqiTtSNQ1vzWc5pXLQHlf/oqGp/bSbi7iedcjcNb6QbCBNG73Euyy2C+l/fn8qKWEwxr+0SSfs3w==, + integrity: sha512-U1xZZXYkvdf5MIWmftU8wrM5PPXzyaY1nGCI4KI4BFfoZxHamsIe+BtnPLIvvPykvQWlVbqUXdLa4aJUuilwLQ==, } cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.27.3': + '@rollup/rollup-win32-x64-msvc@4.31.0': resolution: { - integrity: sha512-vliZLrDmYKyaUoMzEbMTg2JkerfBjn03KmAw9CykO0Zzkzoyd7o3iZNam/TpyWNjNT+Cz2iO3P9Smv2wgrR+Eg==, + integrity: sha512-ul8rnCsUumNln5YWwz0ted2ZHFhzhRRnkpBZ+YRuHoRAlUji9KChpOUOndY7uykrPEPXVbHLlsdo6v5yXo/TXw==, } cpu: [x64] os: [win32] @@ -1166,19 +1174,16 @@ packages: typescript: optional: true - '@typescript-eslint/eslint-plugin@8.15.0': + '@typescript-eslint/eslint-plugin@8.21.0': resolution: { - integrity: sha512-+zkm9AR1Ds9uLWN3fkoeXgFppaQ+uEVtfOV62dDmsy9QCNqlRHWNEck4yarvRNrvRcHQLGfqBNui3cimoz8XAg==, + integrity: sha512-eTH+UOR4I7WbdQnG4Z48ebIA6Bgi7WO8HvFEneeYBxG8qCOYgTOFPSg6ek9ITIDvGjDQzWHcoWHCDO2biByNzA==, } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: '@typescript-eslint/parser': ^8.0.0 || ^8.0.0-alpha.0 eslint: ^8.57.0 || ^9.0.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true + typescript: '>=4.8.4 <5.8.0' '@typescript-eslint/parser@5.62.0': resolution: @@ -1193,18 +1198,15 @@ packages: typescript: optional: true - '@typescript-eslint/parser@8.15.0': + '@typescript-eslint/parser@8.21.0': resolution: { - integrity: sha512-7n59qFpghG4uazrF9qtGKBZXn7Oz4sOMm8dwNWDQY96Xlm2oX67eipqcblDj+oY1lLCbf1oltMZFpUso66Kl1A==, + integrity: sha512-Wy+/sdEH9kI3w9civgACwabHbKl+qIOu0uFZ9IMKzX3Jpv9og0ZBJrZExGrPpFAY7rWsXuxs5e7CPPP17A4eYA==, } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: eslint: ^8.57.0 || ^9.0.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true + typescript: '>=4.8.4 <5.8.0' '@typescript-eslint/scope-manager@5.62.0': resolution: @@ -1213,10 +1215,10 @@ packages: } engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } - '@typescript-eslint/scope-manager@8.15.0': + '@typescript-eslint/scope-manager@8.21.0': resolution: { - integrity: sha512-QRGy8ADi4J7ii95xz4UoiymmmMd/zuy9azCaamnZ3FM8T5fZcex8UfJcjkiEZjJSztKfEBe3dZ5T/5RHAmw2mA==, + integrity: sha512-G3IBKz0/0IPfdeGRMbp+4rbjfSSdnGkXsM/pFZA8zM9t9klXDnB/YnKOBQ0GoPmoROa4bCq2NeHgJa5ydsQ4mA==, } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } @@ -1233,18 +1235,15 @@ packages: typescript: optional: true - '@typescript-eslint/type-utils@8.15.0': + '@typescript-eslint/type-utils@8.21.0': resolution: { - integrity: sha512-UU6uwXDoI3JGSXmcdnP5d8Fffa2KayOhUUqr/AiBnG1Gl7+7ut/oyagVeSkh7bxQ0zSXV9ptRh/4N15nkCqnpw==, + integrity: sha512-95OsL6J2BtzoBxHicoXHxgk3z+9P3BEcQTpBKriqiYzLKnM2DeSqs+sndMKdamU8FosiadQFT3D+BSL9EKnAJQ==, } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: eslint: ^8.57.0 || ^9.0.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true + typescript: '>=4.8.4 <5.8.0' '@typescript-eslint/types@5.62.0': resolution: @@ -1253,10 +1252,10 @@ packages: } engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } - '@typescript-eslint/types@8.15.0': + '@typescript-eslint/types@8.21.0': resolution: { - integrity: sha512-n3Gt8Y/KyJNe0S3yDCD2RVKrHBC4gTUcLTebVBXacPy091E6tNspFLKRXlk3hwT4G55nfr1n2AdFqi/XMxzmPQ==, + integrity: sha512-PAL6LUuQwotLW2a8VsySDBwYMm129vFm4tMVlylzdoTybTHaAi0oBp7Ac6LhSrHHOdLM3efH+nAR6hAWoMF89A==, } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } @@ -1272,17 +1271,14 @@ packages: typescript: optional: true - '@typescript-eslint/typescript-estree@8.15.0': + '@typescript-eslint/typescript-estree@8.21.0': resolution: { - integrity: sha512-1eMp2JgNec/niZsR7ioFBlsh/Fk0oJbhaqO0jRyQBMgkz7RrFfkqF9lYYmBoGBaSiLnu8TAPQTwoTUiSTUW9dg==, + integrity: sha512-x+aeKh/AjAArSauz0GiQZsjT8ciadNMHdkUSwBB9Z6PrKc/4knM4g3UfHml6oDJmKC88a6//cdxnO/+P2LkMcg==, } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true + typescript: '>=4.8.4 <5.8.0' '@typescript-eslint/utils@5.62.0': resolution: @@ -1293,18 +1289,15 @@ packages: peerDependencies: eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 - '@typescript-eslint/utils@8.15.0': + '@typescript-eslint/utils@8.21.0': resolution: { - integrity: sha512-k82RI9yGhr0QM3Dnq+egEpz9qB6Un+WLYhmoNcvl8ltMEededhh7otBVVIDDsEEttauwdY/hQoSsOv13lxrFzQ==, + integrity: sha512-xcXBfcq0Kaxgj7dwejMbFyq7IOHgpNMtVuDveK7w3ZGwG9owKzhALVwKpTF2yrZmEwl9SWdetf3fxNzJQaVuxw==, } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: eslint: ^8.57.0 || ^9.0.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true + typescript: '>=4.8.4 <5.8.0' '@typescript-eslint/visitor-keys@5.62.0': resolution: @@ -1313,10 +1306,10 @@ packages: } engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } - '@typescript-eslint/visitor-keys@8.15.0': + '@typescript-eslint/visitor-keys@8.21.0': resolution: { - integrity: sha512-h8vYOulWec9LhpwfAdZf2bjr8xIp0KNKnpgqSz0qqYYKAW/QZKw3ktRndbiAtUz4acH4QLQavwZBYCc0wulA/Q==, + integrity: sha512-BkLMNpdV6prozk8LlyK/SOoWLmUFi+ZD+pcqti9ILCbVvHGk1ui1g4jJOc2WDLaeExz2qWwojxlPce5PljcT3w==, } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } @@ -1691,17 +1684,17 @@ packages: } engines: { node: '>=10' } - chalk@5.3.0: + chalk@5.4.0: resolution: { - integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==, + integrity: sha512-ZkD35Mx92acjB2yNJgziGqT9oKHEOxjTBTDRpOsRWtdecL/0jM3z5kM/CTzHWvHIen1GvkM85p6TuFfDGfc8/Q==, } engines: { node: ^12.17.0 || ^14.13 || >=16.0.0 } - chalk@5.4.0: + chalk@5.4.1: resolution: { - integrity: sha512-ZkD35Mx92acjB2yNJgziGqT9oKHEOxjTBTDRpOsRWtdecL/0jM3z5kM/CTzHWvHIen1GvkM85p6TuFfDGfc8/Q==, + integrity: sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==, } engines: { node: ^12.17.0 || ^14.13 || >=16.0.0 } @@ -1926,18 +1919,6 @@ packages: supports-color: optional: true - debug@4.3.7: - resolution: - { - integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==, - } - engines: { node: '>=6.0' } - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@4.4.0: resolution: { @@ -2568,6 +2549,13 @@ packages: } engines: { node: '>=8.6.0' } + fast-glob@3.3.3: + resolution: + { + integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==, + } + engines: { node: '>=8.6.0' } + fast-json-stable-stringify@2.1.0: resolution: { @@ -2580,10 +2568,10 @@ packages: integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==, } - fast-uri@3.0.3: + fast-uri@3.0.6: resolution: { - integrity: sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==, + integrity: sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==, } fastq@1.17.1: @@ -2598,10 +2586,10 @@ packages: integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==, } - fdir@6.4.2: + fdir@6.4.3: resolution: { - integrity: sha512-KnhMXsKSPZlAhp7+IjUkRZKPb4fUyccpDrdFXbi4QL1qkmFh9kVY09Yox+n4MaOb3lHZ1Tv829C3oaaXoMYPDQ==, + integrity: sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw==, } peerDependencies: picomatch: ^3 || ^4 @@ -3699,10 +3687,10 @@ packages: integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==, } - lint-staged@15.2.10: + lint-staged@15.4.1: resolution: { - integrity: sha512-5dY5t743e1byO19P9I4b3x8HJwalIznL5E1FWYnU6OWw33KxNBSLAc6Cy7F2PsFEO8FKnLwjwm5hx7aMF0jzZg==, + integrity: sha512-P8yJuVRyLrm5KxCtFx+gjI5Bil+wO7wnTl7C3bXhvtTaAFGirzeB24++D0wGoUwxrUKecNiehemgCob9YL39NA==, } engines: { node: '>=18.12.0' } hasBin: true @@ -4284,10 +4272,10 @@ packages: } engines: { node: '>=6.0.0' } - prettier@3.3.3: + prettier@3.4.2: resolution: { - integrity: sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==, + integrity: sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==, } engines: { node: '>=14' } hasBin: true @@ -4495,10 +4483,10 @@ packages: engines: { node: 20 || >=22 } hasBin: true - rollup@4.27.3: + rollup@4.31.0: resolution: { - integrity: sha512-SLsCOnlmGt9VoZ9Ek8yBK8tAdmPHeppkw+Xa7yDlCEhDTvwYei03JlWo1fdc7YTfLZ4tD8riJCUyAgTbszk1fQ==, + integrity: sha512-9cCE8P4rZLx9+PjoyqHLs31V9a9Vpvfo4qNcs6JCiGWYhw2gijSetFbH6SSy1whnkgcefnUwr8sad7tgqsGvnw==, } engines: { node: '>=18.0.0', npm: '>=8.0.0' } hasBin: true @@ -4891,10 +4879,10 @@ packages: integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==, } - tinyexec@0.3.1: + tinyexec@0.3.2: resolution: { - integrity: sha512-WiCJLEECkO18gwqIp6+hJg0//p23HXp4S+gGtAKu3mI2F2/sXC4FvHvXvB0zJVVaTPhx1/tOwdbRsa1sOBIKqQ==, + integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==, } tmpl@1.0.5: @@ -4910,14 +4898,14 @@ packages: } engines: { node: '>=8.0' } - ts-api-utils@1.4.3: + ts-api-utils@2.0.0: resolution: { - integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==, + integrity: sha512-xCt/TOAc+EOHS1XPnijD3/yzpH6qg2xppZO1YDqGoVsNXfQfzHpOdNuXwrwOU8u4ITXJyDCTyt8w5g1sZv9ynQ==, } - engines: { node: '>=16' } + engines: { node: '>=18.12' } peerDependencies: - typescript: '>=4.2.0' + typescript: '>=4.8.4' ts-jest@29.2.5: resolution: @@ -4958,6 +4946,12 @@ packages: integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==, } + tslib@2.8.1: + resolution: + { + integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==, + } + tsutils@3.21.0: resolution: { @@ -5023,10 +5017,10 @@ packages: } engines: { node: '>= 0.4' } - typescript@5.6.3: + typescript@5.7.3: resolution: { - integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==, + integrity: sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==, } engines: { node: '>=14.17' } hasBin: true @@ -5169,10 +5163,10 @@ packages: integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==, } - yaml@2.5.1: + yaml@2.6.1: resolution: { - integrity: sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==, + integrity: sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==, } engines: { node: '>= 14' } hasBin: true @@ -5410,14 +5404,14 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} - '@commitlint/cli@19.6.0(@types/node@22.10.2)(typescript@5.6.3)': + '@commitlint/cli@19.6.1(@types/node@22.10.2)(typescript@5.7.3)': dependencies: '@commitlint/format': 19.5.0 '@commitlint/lint': 19.6.0 - '@commitlint/load': 19.6.1(@types/node@22.10.2)(typescript@5.6.3) + '@commitlint/load': 19.6.1(@types/node@22.10.2)(typescript@5.7.3) '@commitlint/read': 19.5.0 '@commitlint/types': 19.5.0 - tinyexec: 0.3.1 + tinyexec: 0.3.2 yargs: 17.7.2 transitivePeerDependencies: - '@types/node' @@ -5447,7 +5441,7 @@ snapshots: '@commitlint/format@19.5.0': dependencies: '@commitlint/types': 19.5.0 - chalk: 5.4.0 + chalk: 5.4.1 '@commitlint/is-ignored@19.6.0': dependencies: @@ -5461,15 +5455,15 @@ snapshots: '@commitlint/rules': 19.6.0 '@commitlint/types': 19.5.0 - '@commitlint/load@19.6.1(@types/node@22.10.2)(typescript@5.6.3)': + '@commitlint/load@19.6.1(@types/node@22.10.2)(typescript@5.7.3)': dependencies: '@commitlint/config-validator': 19.5.0 '@commitlint/execute-rule': 19.5.0 '@commitlint/resolve-extends': 19.5.0 '@commitlint/types': 19.5.0 - chalk: 5.4.0 - cosmiconfig: 9.0.0(typescript@5.6.3) - cosmiconfig-typescript-loader: 6.1.0(@types/node@22.10.2)(cosmiconfig@9.0.0(typescript@5.6.3))(typescript@5.6.3) + chalk: 5.4.1 + cosmiconfig: 9.0.0(typescript@5.7.3) + cosmiconfig-typescript-loader: 6.1.0(@types/node@22.10.2)(cosmiconfig@9.0.0(typescript@5.7.3))(typescript@5.7.3) lodash.isplainobject: 4.0.6 lodash.merge: 4.6.2 lodash.uniq: 4.5.0 @@ -5491,7 +5485,7 @@ snapshots: '@commitlint/types': 19.5.0 git-raw-commits: 4.0.0 minimist: 1.2.8 - tinyexec: 0.3.1 + tinyexec: 0.3.2 '@commitlint/resolve-extends@19.5.0': dependencies: @@ -5791,112 +5785,115 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} - '@rollup/plugin-commonjs@28.0.1(rollup@4.27.3)': + '@rollup/plugin-commonjs@28.0.2(rollup@4.31.0)': dependencies: - '@rollup/pluginutils': 5.1.4(rollup@4.27.3) + '@rollup/pluginutils': 5.1.4(rollup@4.31.0) commondir: 1.0.1 estree-walker: 2.0.2 - fdir: 6.4.2(picomatch@4.0.2) + fdir: 6.4.3(picomatch@4.0.2) is-reference: 1.2.1 magic-string: 0.30.17 picomatch: 4.0.2 optionalDependencies: - rollup: 4.27.3 + rollup: 4.31.0 - '@rollup/plugin-node-resolve@15.3.0(rollup@4.27.3)': + '@rollup/plugin-node-resolve@16.0.0(rollup@4.31.0)': dependencies: - '@rollup/pluginutils': 5.1.4(rollup@4.27.3) + '@rollup/pluginutils': 5.1.4(rollup@4.31.0) '@types/resolve': 1.20.2 deepmerge: 4.3.1 is-module: 1.0.0 resolve: 1.22.10 optionalDependencies: - rollup: 4.27.3 + rollup: 4.31.0 - '@rollup/plugin-replace@6.0.2(rollup@4.27.3)': + '@rollup/plugin-replace@6.0.2(rollup@4.31.0)': dependencies: - '@rollup/pluginutils': 5.1.4(rollup@4.27.3) + '@rollup/pluginutils': 5.1.4(rollup@4.31.0) magic-string: 0.30.17 optionalDependencies: - rollup: 4.27.3 + rollup: 4.31.0 - '@rollup/plugin-terser@0.4.4(rollup@4.27.3)': + '@rollup/plugin-terser@0.4.4(rollup@4.31.0)': dependencies: serialize-javascript: 6.0.2 smob: 1.5.0 terser: 5.37.0 optionalDependencies: - rollup: 4.27.3 + rollup: 4.31.0 - '@rollup/plugin-typescript@12.1.1(rollup@4.27.3)(tslib@1.14.1)(typescript@5.6.3)': + '@rollup/plugin-typescript@12.1.2(rollup@4.31.0)(tslib@2.8.1)(typescript@5.7.3)': dependencies: - '@rollup/pluginutils': 5.1.4(rollup@4.27.3) + '@rollup/pluginutils': 5.1.4(rollup@4.31.0) resolve: 1.22.10 - typescript: 5.6.3 + typescript: 5.7.3 optionalDependencies: - rollup: 4.27.3 - tslib: 1.14.1 + rollup: 4.31.0 + tslib: 2.8.1 - '@rollup/pluginutils@5.1.4(rollup@4.27.3)': + '@rollup/pluginutils@5.1.4(rollup@4.31.0)': dependencies: '@types/estree': 1.0.6 estree-walker: 2.0.2 picomatch: 4.0.2 optionalDependencies: - rollup: 4.27.3 + rollup: 4.31.0 - '@rollup/rollup-android-arm-eabi@4.27.3': + '@rollup/rollup-android-arm-eabi@4.31.0': optional: true - '@rollup/rollup-android-arm64@4.27.3': + '@rollup/rollup-android-arm64@4.31.0': optional: true - '@rollup/rollup-darwin-arm64@4.27.3': + '@rollup/rollup-darwin-arm64@4.31.0': optional: true - '@rollup/rollup-darwin-x64@4.27.3': + '@rollup/rollup-darwin-x64@4.31.0': optional: true - '@rollup/rollup-freebsd-arm64@4.27.3': + '@rollup/rollup-freebsd-arm64@4.31.0': optional: true - '@rollup/rollup-freebsd-x64@4.27.3': + '@rollup/rollup-freebsd-x64@4.31.0': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.27.3': + '@rollup/rollup-linux-arm-gnueabihf@4.31.0': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.27.3': + '@rollup/rollup-linux-arm-musleabihf@4.31.0': optional: true - '@rollup/rollup-linux-arm64-gnu@4.27.3': + '@rollup/rollup-linux-arm64-gnu@4.31.0': optional: true - '@rollup/rollup-linux-arm64-musl@4.27.3': + '@rollup/rollup-linux-arm64-musl@4.31.0': optional: true - '@rollup/rollup-linux-powerpc64le-gnu@4.27.3': + '@rollup/rollup-linux-loongarch64-gnu@4.31.0': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.27.3': + '@rollup/rollup-linux-powerpc64le-gnu@4.31.0': optional: true - '@rollup/rollup-linux-s390x-gnu@4.27.3': + '@rollup/rollup-linux-riscv64-gnu@4.31.0': optional: true - '@rollup/rollup-linux-x64-gnu@4.27.3': + '@rollup/rollup-linux-s390x-gnu@4.31.0': optional: true - '@rollup/rollup-linux-x64-musl@4.27.3': + '@rollup/rollup-linux-x64-gnu@4.31.0': optional: true - '@rollup/rollup-win32-arm64-msvc@4.27.3': + '@rollup/rollup-linux-x64-musl@4.31.0': optional: true - '@rollup/rollup-win32-ia32-msvc@4.27.3': + '@rollup/rollup-win32-arm64-msvc@4.31.0': optional: true - '@rollup/rollup-win32-x64-msvc@4.27.3': + '@rollup/rollup-win32-ia32-msvc@4.31.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.31.0': optional: true '@rtsao/scc@1.1.0': {} @@ -5911,28 +5908,28 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 - '@strapi/eslint-config@0.2.1(@babel/core@7.26.0)(prettier@3.3.3)(typescript@5.6.3)': + '@strapi/eslint-config@0.2.1(@babel/core@7.26.0)(prettier@3.4.2)(typescript@5.7.3)': dependencies: '@babel/eslint-parser': 7.25.9(@babel/core@7.26.0)(eslint@8.45.0) - '@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.6.3))(eslint@8.45.0)(typescript@5.6.3) - '@typescript-eslint/parser': 5.62.0(eslint@8.45.0)(typescript@5.6.3) + '@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.7.3))(eslint@8.45.0)(typescript@5.7.3) + '@typescript-eslint/parser': 5.62.0(eslint@8.45.0)(typescript@5.7.3) eslint: 8.45.0 - eslint-config-airbnb: 19.0.4(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.6.3))(eslint-import-resolver-typescript@3.7.0)(eslint@8.45.0))(eslint-plugin-jsx-a11y@6.10.2(eslint@8.45.0))(eslint-plugin-react-hooks@4.6.2(eslint@8.45.0))(eslint-plugin-react@7.37.2(eslint@8.45.0))(eslint@8.45.0) - eslint-config-airbnb-base: 15.0.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.6.3))(eslint-import-resolver-typescript@3.7.0)(eslint@8.45.0))(eslint@8.45.0) - eslint-config-airbnb-typescript: 17.1.0(@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.6.3))(eslint@8.45.0)(typescript@5.6.3))(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.6.3))(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.6.3))(eslint-import-resolver-typescript@3.7.0)(eslint@8.45.0))(eslint@8.45.0) + eslint-config-airbnb: 19.0.4(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.7.0)(eslint@8.45.0))(eslint-plugin-jsx-a11y@6.10.2(eslint@8.45.0))(eslint-plugin-react-hooks@4.6.2(eslint@8.45.0))(eslint-plugin-react@7.37.2(eslint@8.45.0))(eslint@8.45.0) + eslint-config-airbnb-base: 15.0.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.7.0)(eslint@8.45.0))(eslint@8.45.0) + eslint-config-airbnb-typescript: 17.1.0(@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.7.3))(eslint@8.45.0)(typescript@5.7.3))(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.7.3))(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.7.0)(eslint@8.45.0))(eslint@8.45.0) eslint-config-prettier: 8.10.0(eslint@8.45.0) eslint-import-resolver-typescript: 3.7.0(eslint-plugin-import@2.31.0)(eslint@8.45.0) eslint-plugin-check-file: 2.8.0(eslint@8.45.0) - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.6.3))(eslint-import-resolver-typescript@3.7.0)(eslint@8.45.0) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.7.0)(eslint@8.45.0) eslint-plugin-jest-dom: 4.0.3(eslint@8.45.0) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-node: 11.1.0(eslint@8.45.0) - eslint-plugin-prettier: 4.2.1(eslint-config-prettier@8.10.0(eslint@8.45.0))(eslint@8.45.0)(prettier@3.3.3) + eslint-plugin-prettier: 4.2.1(eslint-config-prettier@8.10.0(eslint@8.45.0))(eslint@8.45.0)(prettier@3.4.2) eslint-plugin-react: 7.37.2(eslint@8.57.1) eslint-plugin-react-hooks: 4.6.2(eslint@8.57.1) - eslint-plugin-testing-library: 5.11.1(eslint@8.45.0)(typescript@5.6.3) + eslint-plugin-testing-library: 5.11.1(eslint@8.45.0)(typescript@5.7.3) optionalDependencies: - typescript: 5.6.3 + typescript: 5.7.3 transitivePeerDependencies: - '@babel/core' - eslint-import-resolver-webpack @@ -6025,65 +6022,63 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.6.3))(eslint@8.45.0)(typescript@5.6.3)': + '@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.7.3))(eslint@8.45.0)(typescript@5.7.3)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 5.62.0(eslint@8.45.0)(typescript@5.6.3) + '@typescript-eslint/parser': 5.62.0(eslint@8.45.0)(typescript@5.7.3) '@typescript-eslint/scope-manager': 5.62.0 - '@typescript-eslint/type-utils': 5.62.0(eslint@8.45.0)(typescript@5.6.3) - '@typescript-eslint/utils': 5.62.0(eslint@8.45.0)(typescript@5.6.3) + '@typescript-eslint/type-utils': 5.62.0(eslint@8.45.0)(typescript@5.7.3) + '@typescript-eslint/utils': 5.62.0(eslint@8.45.0)(typescript@5.7.3) debug: 4.4.0 eslint: 8.45.0 graphemer: 1.4.0 ignore: 5.3.2 natural-compare-lite: 1.4.0 semver: 7.6.3 - tsutils: 3.21.0(typescript@5.6.3) + tsutils: 3.21.0(typescript@5.7.3) optionalDependencies: - typescript: 5.6.3 + typescript: 5.7.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@8.15.0(@typescript-eslint/parser@8.15.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1)(typescript@5.6.3)': + '@typescript-eslint/eslint-plugin@8.21.0(@typescript-eslint/parser@8.21.0(eslint@8.57.1)(typescript@5.7.3))(eslint@8.57.1)(typescript@5.7.3)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.15.0(eslint@8.57.1)(typescript@5.6.3) - '@typescript-eslint/scope-manager': 8.15.0 - '@typescript-eslint/type-utils': 8.15.0(eslint@8.57.1)(typescript@5.6.3) - '@typescript-eslint/utils': 8.15.0(eslint@8.57.1)(typescript@5.6.3) - '@typescript-eslint/visitor-keys': 8.15.0 + '@typescript-eslint/parser': 8.21.0(eslint@8.57.1)(typescript@5.7.3) + '@typescript-eslint/scope-manager': 8.21.0 + '@typescript-eslint/type-utils': 8.21.0(eslint@8.57.1)(typescript@5.7.3) + '@typescript-eslint/utils': 8.21.0(eslint@8.57.1)(typescript@5.7.3) + '@typescript-eslint/visitor-keys': 8.21.0 eslint: 8.57.1 graphemer: 1.4.0 ignore: 5.3.2 natural-compare: 1.4.0 - ts-api-utils: 1.4.3(typescript@5.6.3) - optionalDependencies: - typescript: 5.6.3 + ts-api-utils: 2.0.0(typescript@5.7.3) + typescript: 5.7.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.6.3)': + '@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.7.3)': dependencies: '@typescript-eslint/scope-manager': 5.62.0 '@typescript-eslint/types': 5.62.0 - '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.6.3) + '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.7.3) debug: 4.4.0 eslint: 8.45.0 optionalDependencies: - typescript: 5.6.3 + typescript: 5.7.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.15.0(eslint@8.57.1)(typescript@5.6.3)': + '@typescript-eslint/parser@8.21.0(eslint@8.57.1)(typescript@5.7.3)': dependencies: - '@typescript-eslint/scope-manager': 8.15.0 - '@typescript-eslint/types': 8.15.0 - '@typescript-eslint/typescript-estree': 8.15.0(typescript@5.6.3) - '@typescript-eslint/visitor-keys': 8.15.0 + '@typescript-eslint/scope-manager': 8.21.0 + '@typescript-eslint/types': 8.21.0 + '@typescript-eslint/typescript-estree': 8.21.0(typescript@5.7.3) + '@typescript-eslint/visitor-keys': 8.21.0 debug: 4.4.0 eslint: 8.57.1 - optionalDependencies: - typescript: 5.6.3 + typescript: 5.7.3 transitivePeerDependencies: - supports-color @@ -6092,40 +6087,39 @@ snapshots: '@typescript-eslint/types': 5.62.0 '@typescript-eslint/visitor-keys': 5.62.0 - '@typescript-eslint/scope-manager@8.15.0': + '@typescript-eslint/scope-manager@8.21.0': dependencies: - '@typescript-eslint/types': 8.15.0 - '@typescript-eslint/visitor-keys': 8.15.0 + '@typescript-eslint/types': 8.21.0 + '@typescript-eslint/visitor-keys': 8.21.0 - '@typescript-eslint/type-utils@5.62.0(eslint@8.45.0)(typescript@5.6.3)': + '@typescript-eslint/type-utils@5.62.0(eslint@8.45.0)(typescript@5.7.3)': dependencies: - '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.6.3) - '@typescript-eslint/utils': 5.62.0(eslint@8.45.0)(typescript@5.6.3) + '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.7.3) + '@typescript-eslint/utils': 5.62.0(eslint@8.45.0)(typescript@5.7.3) debug: 4.4.0 eslint: 8.45.0 - tsutils: 3.21.0(typescript@5.6.3) + tsutils: 3.21.0(typescript@5.7.3) optionalDependencies: - typescript: 5.6.3 + typescript: 5.7.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@8.15.0(eslint@8.57.1)(typescript@5.6.3)': + '@typescript-eslint/type-utils@8.21.0(eslint@8.57.1)(typescript@5.7.3)': dependencies: - '@typescript-eslint/typescript-estree': 8.15.0(typescript@5.6.3) - '@typescript-eslint/utils': 8.15.0(eslint@8.57.1)(typescript@5.6.3) + '@typescript-eslint/typescript-estree': 8.21.0(typescript@5.7.3) + '@typescript-eslint/utils': 8.21.0(eslint@8.57.1)(typescript@5.7.3) debug: 4.4.0 eslint: 8.57.1 - ts-api-utils: 1.4.3(typescript@5.6.3) - optionalDependencies: - typescript: 5.6.3 + ts-api-utils: 2.0.0(typescript@5.7.3) + typescript: 5.7.3 transitivePeerDependencies: - supports-color '@typescript-eslint/types@5.62.0': {} - '@typescript-eslint/types@8.15.0': {} + '@typescript-eslint/types@8.21.0': {} - '@typescript-eslint/typescript-estree@5.62.0(typescript@5.6.3)': + '@typescript-eslint/typescript-estree@5.62.0(typescript@5.7.3)': dependencies: '@typescript-eslint/types': 5.62.0 '@typescript-eslint/visitor-keys': 5.62.0 @@ -6133,35 +6127,34 @@ snapshots: globby: 11.1.0 is-glob: 4.0.3 semver: 7.6.3 - tsutils: 3.21.0(typescript@5.6.3) + tsutils: 3.21.0(typescript@5.7.3) optionalDependencies: - typescript: 5.6.3 + typescript: 5.7.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.15.0(typescript@5.6.3)': + '@typescript-eslint/typescript-estree@8.21.0(typescript@5.7.3)': dependencies: - '@typescript-eslint/types': 8.15.0 - '@typescript-eslint/visitor-keys': 8.15.0 + '@typescript-eslint/types': 8.21.0 + '@typescript-eslint/visitor-keys': 8.21.0 debug: 4.4.0 - fast-glob: 3.3.2 + fast-glob: 3.3.3 is-glob: 4.0.3 minimatch: 9.0.5 semver: 7.6.3 - ts-api-utils: 1.4.3(typescript@5.6.3) - optionalDependencies: - typescript: 5.6.3 + ts-api-utils: 2.0.0(typescript@5.7.3) + typescript: 5.7.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@5.62.0(eslint@8.45.0)(typescript@5.6.3)': + '@typescript-eslint/utils@5.62.0(eslint@8.45.0)(typescript@5.7.3)': dependencies: '@eslint-community/eslint-utils': 4.4.1(eslint@8.45.0) '@types/json-schema': 7.0.15 '@types/semver': 7.5.8 '@typescript-eslint/scope-manager': 5.62.0 '@typescript-eslint/types': 5.62.0 - '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.6.3) + '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.7.3) eslint: 8.45.0 eslint-scope: 5.1.1 semver: 7.6.3 @@ -6169,15 +6162,14 @@ snapshots: - supports-color - typescript - '@typescript-eslint/utils@8.15.0(eslint@8.57.1)(typescript@5.6.3)': + '@typescript-eslint/utils@8.21.0(eslint@8.57.1)(typescript@5.7.3)': dependencies: '@eslint-community/eslint-utils': 4.4.1(eslint@8.57.1) - '@typescript-eslint/scope-manager': 8.15.0 - '@typescript-eslint/types': 8.15.0 - '@typescript-eslint/typescript-estree': 8.15.0(typescript@5.6.3) + '@typescript-eslint/scope-manager': 8.21.0 + '@typescript-eslint/types': 8.21.0 + '@typescript-eslint/typescript-estree': 8.21.0(typescript@5.7.3) eslint: 8.57.1 - optionalDependencies: - typescript: 5.6.3 + typescript: 5.7.3 transitivePeerDependencies: - supports-color @@ -6186,9 +6178,9 @@ snapshots: '@typescript-eslint/types': 5.62.0 eslint-visitor-keys: 3.4.3 - '@typescript-eslint/visitor-keys@8.15.0': + '@typescript-eslint/visitor-keys@8.21.0': dependencies: - '@typescript-eslint/types': 8.15.0 + '@typescript-eslint/types': 8.21.0 eslint-visitor-keys: 4.2.0 '@ungap/structured-clone@1.2.1': {} @@ -6214,7 +6206,7 @@ snapshots: ajv@8.17.1: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.0.3 + fast-uri: 3.0.6 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -6452,10 +6444,10 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 - chalk@5.3.0: {} - chalk@5.4.0: {} + chalk@5.4.1: {} + char-regex@1.0.2: {} ci-info@3.9.0: {} @@ -6521,21 +6513,21 @@ snapshots: convert-source-map@2.0.0: {} - cosmiconfig-typescript-loader@6.1.0(@types/node@22.10.2)(cosmiconfig@9.0.0(typescript@5.6.3))(typescript@5.6.3): + cosmiconfig-typescript-loader@6.1.0(@types/node@22.10.2)(cosmiconfig@9.0.0(typescript@5.7.3))(typescript@5.7.3): dependencies: '@types/node': 22.10.2 - cosmiconfig: 9.0.0(typescript@5.6.3) + cosmiconfig: 9.0.0(typescript@5.7.3) jiti: 2.4.2 - typescript: 5.6.3 + typescript: 5.7.3 - cosmiconfig@9.0.0(typescript@5.6.3): + cosmiconfig@9.0.0(typescript@5.7.3): dependencies: env-paths: 2.2.1 import-fresh: 3.3.0 js-yaml: 4.1.0 parse-json: 5.2.0 optionalDependencies: - typescript: 5.6.3 + typescript: 5.7.3 create-jest@29.7.0(@types/node@22.10.2): dependencies: @@ -6584,10 +6576,6 @@ snapshots: dependencies: ms: 2.1.3 - debug@4.3.7: - dependencies: - ms: 2.1.3 - debug@4.4.0: dependencies: ms: 2.1.3 @@ -6799,28 +6787,28 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-airbnb-base@15.0.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.6.3))(eslint-import-resolver-typescript@3.7.0)(eslint@8.45.0))(eslint@8.45.0): + eslint-config-airbnb-base@15.0.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.7.0)(eslint@8.45.0))(eslint@8.45.0): dependencies: confusing-browser-globals: 1.0.11 eslint: 8.45.0 - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.6.3))(eslint-import-resolver-typescript@3.7.0)(eslint@8.45.0) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.7.0)(eslint@8.45.0) object.assign: 4.1.7 object.entries: 1.1.8 semver: 6.3.1 - eslint-config-airbnb-typescript@17.1.0(@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.6.3))(eslint@8.45.0)(typescript@5.6.3))(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.6.3))(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.6.3))(eslint-import-resolver-typescript@3.7.0)(eslint@8.45.0))(eslint@8.45.0): + eslint-config-airbnb-typescript@17.1.0(@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.7.3))(eslint@8.45.0)(typescript@5.7.3))(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.7.3))(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.7.0)(eslint@8.45.0))(eslint@8.45.0): dependencies: - '@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.6.3))(eslint@8.45.0)(typescript@5.6.3) - '@typescript-eslint/parser': 5.62.0(eslint@8.45.0)(typescript@5.6.3) + '@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.7.3))(eslint@8.45.0)(typescript@5.7.3) + '@typescript-eslint/parser': 5.62.0(eslint@8.45.0)(typescript@5.7.3) eslint: 8.45.0 - eslint-config-airbnb-base: 15.0.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.6.3))(eslint-import-resolver-typescript@3.7.0)(eslint@8.45.0))(eslint@8.45.0) - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.6.3))(eslint-import-resolver-typescript@3.7.0)(eslint@8.45.0) + eslint-config-airbnb-base: 15.0.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.7.0)(eslint@8.45.0))(eslint@8.45.0) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.7.0)(eslint@8.45.0) - eslint-config-airbnb@19.0.4(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.6.3))(eslint-import-resolver-typescript@3.7.0)(eslint@8.45.0))(eslint-plugin-jsx-a11y@6.10.2(eslint@8.45.0))(eslint-plugin-react-hooks@4.6.2(eslint@8.45.0))(eslint-plugin-react@7.37.2(eslint@8.45.0))(eslint@8.45.0): + eslint-config-airbnb@19.0.4(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.7.0)(eslint@8.45.0))(eslint-plugin-jsx-a11y@6.10.2(eslint@8.45.0))(eslint-plugin-react-hooks@4.6.2(eslint@8.45.0))(eslint-plugin-react@7.37.2(eslint@8.45.0))(eslint@8.45.0): dependencies: eslint: 8.45.0 - eslint-config-airbnb-base: 15.0.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.6.3))(eslint-import-resolver-typescript@3.7.0)(eslint@8.45.0))(eslint@8.45.0) - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.6.3))(eslint-import-resolver-typescript@3.7.0)(eslint@8.45.0) + eslint-config-airbnb-base: 15.0.0(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.7.0)(eslint@8.45.0))(eslint@8.45.0) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.7.0)(eslint@8.45.0) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.2(eslint@8.57.1) eslint-plugin-react-hooks: 4.6.2(eslint@8.57.1) @@ -6851,26 +6839,26 @@ snapshots: is-glob: 4.0.3 stable-hash: 0.0.4 optionalDependencies: - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.6.3))(eslint-import-resolver-typescript@3.7.0)(eslint@8.45.0) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.7.0)(eslint@8.45.0) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.7.0(eslint-plugin-import@2.31.0)(eslint@8.45.0))(eslint@8.45.0): + eslint-module-utils@2.12.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.7.0(eslint-plugin-import@2.31.0)(eslint@8.45.0))(eslint@8.45.0): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 5.62.0(eslint@8.45.0)(typescript@5.6.3) + '@typescript-eslint/parser': 5.62.0(eslint@8.45.0)(typescript@5.7.3) eslint: 8.45.0 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.7.0(eslint-plugin-import@2.31.0)(eslint@8.45.0) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@8.15.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint@8.57.1): + eslint-module-utils@2.12.0(@typescript-eslint/parser@8.21.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.15.0(eslint@8.57.1)(typescript@5.6.3) + '@typescript-eslint/parser': 8.21.0(eslint@8.57.1)(typescript@5.7.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 transitivePeerDependencies: @@ -6888,7 +6876,7 @@ snapshots: eslint-utils: 2.1.0 regexpp: 3.2.0 - eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.6.3))(eslint-import-resolver-typescript@3.7.0)(eslint@8.45.0): + eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.7.3))(eslint-import-resolver-typescript@3.7.0)(eslint@8.45.0): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.8 @@ -6899,7 +6887,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.45.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.7.0(eslint-plugin-import@2.31.0)(eslint@8.45.0))(eslint@8.45.0) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@5.62.0(eslint@8.45.0)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.7.0(eslint-plugin-import@2.31.0)(eslint@8.45.0))(eslint@8.45.0) hasown: 2.0.2 is-core-module: 2.16.0 is-glob: 4.0.3 @@ -6911,13 +6899,13 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 5.62.0(eslint@8.45.0)(typescript@5.6.3) + '@typescript-eslint/parser': 5.62.0(eslint@8.45.0)(typescript@5.7.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.15.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1): + eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.21.0(eslint@8.57.1)(typescript@5.7.3))(eslint@8.57.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.8 @@ -6928,7 +6916,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.15.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint@8.57.1) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.21.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.16.0 is-glob: 4.0.3 @@ -6940,7 +6928,7 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.15.0(eslint@8.57.1)(typescript@5.6.3) + '@typescript-eslint/parser': 8.21.0(eslint@8.57.1)(typescript@5.7.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -6982,10 +6970,10 @@ snapshots: resolve: 1.22.10 semver: 6.3.1 - eslint-plugin-prettier@4.2.1(eslint-config-prettier@8.10.0(eslint@8.45.0))(eslint@8.45.0)(prettier@3.3.3): + eslint-plugin-prettier@4.2.1(eslint-config-prettier@8.10.0(eslint@8.45.0))(eslint@8.45.0)(prettier@3.4.2): dependencies: eslint: 8.45.0 - prettier: 3.3.3 + prettier: 3.4.2 prettier-linter-helpers: 1.0.0 optionalDependencies: eslint-config-prettier: 8.10.0(eslint@8.45.0) @@ -7016,9 +7004,9 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 - eslint-plugin-testing-library@5.11.1(eslint@8.45.0)(typescript@5.6.3): + eslint-plugin-testing-library@5.11.1(eslint@8.45.0)(typescript@5.7.3): dependencies: - '@typescript-eslint/utils': 5.62.0(eslint@8.45.0)(typescript@5.6.3) + '@typescript-eslint/utils': 5.62.0(eslint@8.45.0)(typescript@5.7.3) eslint: 8.45.0 transitivePeerDependencies: - supports-color @@ -7203,11 +7191,19 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} - fast-uri@3.0.3: {} + fast-uri@3.0.6: {} fastq@1.17.1: dependencies: @@ -7217,7 +7213,7 @@ snapshots: dependencies: bser: 2.1.1 - fdir@6.4.2(picomatch@4.0.2): + fdir@6.4.3(picomatch@4.0.2): optionalDependencies: picomatch: 4.0.2 @@ -8017,18 +8013,18 @@ snapshots: lines-and-columns@1.2.4: {} - lint-staged@15.2.10: + lint-staged@15.4.1: dependencies: - chalk: 5.3.0 + chalk: 5.4.1 commander: 12.1.0 - debug: 4.3.7 + debug: 4.4.0 execa: 8.0.1 lilconfig: 3.1.3 listr2: 8.2.5 micromatch: 4.0.8 pidtree: 0.6.0 string-argv: 0.3.2 - yaml: 2.5.1 + yaml: 2.6.1 transitivePeerDependencies: - supports-color @@ -8317,7 +8313,7 @@ snapshots: dependencies: fast-diff: 1.3.0 - prettier@3.3.3: {} + prettier@3.4.2: {} pretty-format@27.5.1: dependencies: @@ -8428,28 +8424,29 @@ snapshots: glob: 11.0.0 package-json-from-dist: 1.0.1 - rollup@4.27.3: + rollup@4.31.0: dependencies: '@types/estree': 1.0.6 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.27.3 - '@rollup/rollup-android-arm64': 4.27.3 - '@rollup/rollup-darwin-arm64': 4.27.3 - '@rollup/rollup-darwin-x64': 4.27.3 - '@rollup/rollup-freebsd-arm64': 4.27.3 - '@rollup/rollup-freebsd-x64': 4.27.3 - '@rollup/rollup-linux-arm-gnueabihf': 4.27.3 - '@rollup/rollup-linux-arm-musleabihf': 4.27.3 - '@rollup/rollup-linux-arm64-gnu': 4.27.3 - '@rollup/rollup-linux-arm64-musl': 4.27.3 - '@rollup/rollup-linux-powerpc64le-gnu': 4.27.3 - '@rollup/rollup-linux-riscv64-gnu': 4.27.3 - '@rollup/rollup-linux-s390x-gnu': 4.27.3 - '@rollup/rollup-linux-x64-gnu': 4.27.3 - '@rollup/rollup-linux-x64-musl': 4.27.3 - '@rollup/rollup-win32-arm64-msvc': 4.27.3 - '@rollup/rollup-win32-ia32-msvc': 4.27.3 - '@rollup/rollup-win32-x64-msvc': 4.27.3 + '@rollup/rollup-android-arm-eabi': 4.31.0 + '@rollup/rollup-android-arm64': 4.31.0 + '@rollup/rollup-darwin-arm64': 4.31.0 + '@rollup/rollup-darwin-x64': 4.31.0 + '@rollup/rollup-freebsd-arm64': 4.31.0 + '@rollup/rollup-freebsd-x64': 4.31.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.31.0 + '@rollup/rollup-linux-arm-musleabihf': 4.31.0 + '@rollup/rollup-linux-arm64-gnu': 4.31.0 + '@rollup/rollup-linux-arm64-musl': 4.31.0 + '@rollup/rollup-linux-loongarch64-gnu': 4.31.0 + '@rollup/rollup-linux-powerpc64le-gnu': 4.31.0 + '@rollup/rollup-linux-riscv64-gnu': 4.31.0 + '@rollup/rollup-linux-s390x-gnu': 4.31.0 + '@rollup/rollup-linux-x64-gnu': 4.31.0 + '@rollup/rollup-linux-x64-musl': 4.31.0 + '@rollup/rollup-win32-arm64-msvc': 4.31.0 + '@rollup/rollup-win32-ia32-msvc': 4.31.0 + '@rollup/rollup-win32-x64-msvc': 4.31.0 fsevents: 2.3.3 run-parallel@1.2.0: @@ -8701,7 +8698,7 @@ snapshots: through@2.3.8: {} - tinyexec@0.3.1: {} + tinyexec@0.3.2: {} tmpl@1.0.5: {} @@ -8709,11 +8706,11 @@ snapshots: dependencies: is-number: 7.0.0 - ts-api-utils@1.4.3(typescript@5.6.3): + ts-api-utils@2.0.0(typescript@5.7.3): dependencies: - typescript: 5.6.3 + typescript: 5.7.3 - ts-jest@29.2.5(@babel/core@7.26.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.0))(jest@29.7.0(@types/node@22.10.2))(typescript@5.6.3): + ts-jest@29.2.5(@babel/core@7.26.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.0))(jest@29.7.0(@types/node@22.10.2))(typescript@5.7.3): dependencies: bs-logger: 0.2.6 ejs: 3.1.10 @@ -8724,7 +8721,7 @@ snapshots: lodash.memoize: 4.1.2 make-error: 1.3.6 semver: 7.6.3 - typescript: 5.6.3 + typescript: 5.7.3 yargs-parser: 21.1.1 optionalDependencies: '@babel/core': 7.26.0 @@ -8741,10 +8738,13 @@ snapshots: tslib@1.14.1: {} - tsutils@3.21.0(typescript@5.6.3): + tslib@2.8.1: + optional: true + + tsutils@3.21.0(typescript@5.7.3): dependencies: tslib: 1.14.1 - typescript: 5.6.3 + typescript: 5.7.3 type-check@0.4.0: dependencies: @@ -8789,7 +8789,7 @@ snapshots: possible-typed-array-names: 1.0.0 reflect.getprototypeof: 1.0.9 - typescript@5.6.3: {} + typescript@5.7.3: {} unbox-primitive@1.1.0: dependencies: @@ -8897,7 +8897,7 @@ snapshots: yallist@3.1.1: {} - yaml@2.5.1: {} + yaml@2.6.1: {} yargs-parser@21.1.1: {} diff --git a/src/auth/manager.ts b/src/auth/manager.ts index b98b767..d584217 100644 --- a/src/auth/manager.ts +++ b/src/auth/manager.ts @@ -1,7 +1,6 @@ import createDebug from 'debug'; import { HttpClient } from '../http'; -import { URLHelper } from '../utilities'; import { AuthProviderFactory } from './factory'; import { ApiTokenAuthProvider, UsersPermissionsAuthProvider } from './providers'; @@ -115,13 +114,16 @@ export class AuthManager { try { debug('trying to authenticate with %s', this._authProvider.name); - await this._authProvider.authenticate(http); + // Create and use a client free of any custom interceptor to avoid infinite auth loop + const client = http.create(undefined, false); + + await this._authProvider.authenticate(client); this._isAuthenticated = true; debug('authentication successful'); - } catch { - debug('authentication failed'); + } catch (e) { + debug(`authentication failed: ${e}`); this._isAuthenticated = false; } } @@ -140,21 +142,23 @@ export class AuthManager { * console.log(request.headers.get('Authorization')) // 'Bearer ' * ``` */ - authenticateRequest(request: Request) { - if (this._authProvider) { - const { headers } = this._authProvider; - - for (const [key, value] of Object.entries(headers)) { - request.headers.set(key, value); - - debug('added %o header to %o query', key, URLHelper.toReadablePath(request.url)); - } - } else { - debug( - 'no auth provider is set. skipping headers for %s query', - URLHelper.toReadablePath(request.url) + authenticateRequest(request: RequestInit) { + // If no auth provider is set, skip + if (!this._authProvider) { + return; + } + + const { headers } = request; + + if (!(headers instanceof Headers)) { + throw new Error( + `Invalid request headers, headers must be an instance of Headers but found "${typeof headers}"` ); } + + for (const [key, value] of Object.entries(this._authProvider.headers)) { + headers.set(key, value); + } } /** diff --git a/src/auth/providers/api-token.ts b/src/auth/providers/api-token.ts index 3b89e1f..c5220cb 100644 --- a/src/auth/providers/api-token.ts +++ b/src/auth/providers/api-token.ts @@ -31,18 +31,18 @@ export class ApiTokenAuthProvider extends AbstractAuthProvider { - const { baseURL } = httpClient; - const { identifier, password } = this.credentials; - - const localAuthURL = `${baseURL}/auth/local`; - - debug('trying to authenticate with %o as %o at %o ', this.name, identifier, localAuthURL); - - const request = new Request(localAuthURL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ identifier, password }), - }); + const { identifier, password } = this._credentials; + + debug( + 'trying to authenticate with %o as %o at %o ', + this.name, + identifier, + LOCAL_AUTH_ENDPOINT + ); + + const response = await httpClient.post( + LOCAL_AUTH_ENDPOINT, + JSON.stringify({ identifier, password }), + { + headers: { 'Content-Type': 'application/json' }, + } + ); + + if (!response.ok) { + throw new Error(response.statusText); + } - // Make sure to use the HttpClient's "_fetch" method to not perform authentication in an infinite loop. - const response = await httpClient._fetch(request); const data = await response.json(); const obfuscatedToken = data.jwt.slice(0, 5) + '...' + data.jwt.slice(-5); diff --git a/src/content-types/collection/manager.ts b/src/content-types/collection/manager.ts index 1099fc2..859de8a 100644 --- a/src/content-types/collection/manager.ts +++ b/src/content-types/collection/manager.ts @@ -73,7 +73,7 @@ export class CollectionTypeManager { url = URLHelper.appendQueryParams(url, queryParams); } - const response = await this._httpClient.fetch(url, { method: 'GET' }); + const response = await this._httpClient.get(url); const json = await response.json(); debug('found %o %o documents', Number(json?.data?.length), this._pluralName); @@ -115,7 +115,7 @@ export class CollectionTypeManager { url = URLHelper.appendQueryParams(url, queryParams); } - const response = await this._httpClient.fetch(url, { method: 'GET' }); + const response = await this._httpClient.get(url); debug('found the %o document with document id %o', this._pluralName, documentID); @@ -152,10 +152,7 @@ export class CollectionTypeManager { url = URLHelper.appendQueryParams(url, queryParams); } - const response = await this._httpClient.fetch(url, { - method: 'POST', - body: JSON.stringify({ data }), - }); + const response = await this._httpClient.post(url, JSON.stringify({ data })); debug('created the %o document', this._pluralName); @@ -201,10 +198,7 @@ export class CollectionTypeManager { url = URLHelper.appendQueryParams(url, queryParams); } - const response = await this._httpClient.fetch(url, { - method: 'PUT', - body: JSON.stringify({ data }), - }); + const response = await this._httpClient.put(url, JSON.stringify({ data })); debug('updated the %o document with id %o', this._pluralName, documentID); @@ -243,7 +237,7 @@ export class CollectionTypeManager { url = URLHelper.appendQueryParams(url, queryParams); } - await this._httpClient.fetch(url, { method: 'DELETE' }); + await this._httpClient.delete(url); debug('deleted the %o document with id %o', this._pluralName, documentID); } diff --git a/src/content-types/single/manager.ts b/src/content-types/single/manager.ts index 201cb71..d60bf6d 100644 --- a/src/content-types/single/manager.ts +++ b/src/content-types/single/manager.ts @@ -69,7 +69,7 @@ export class SingleTypeManager { path = URLHelper.appendQueryParams(path, queryParams); } - const response = await this._httpClient.fetch(path, { method: 'GET' }); + const response = await this._httpClient.get(path); debug('the %o document has been fetched', this._singularName); @@ -113,10 +113,7 @@ export class SingleTypeManager { url = URLHelper.appendQueryParams(url, queryParams); } - const response = await this._httpClient.fetch(url, { - method: 'PUT', - body: JSON.stringify({ data }), - }); + const response = await this._httpClient.put(url, JSON.stringify({ data })); debug('the %o document has been updated', this._singularName); @@ -156,7 +153,7 @@ export class SingleTypeManager { url = URLHelper.appendQueryParams(url, queryParams); } - await this._httpClient.fetch(url, { method: 'DELETE' }); + await this._httpClient.delete(url); debug('the %o document has been deleted', this._singularName); } diff --git a/src/formatters/index.ts b/src/formatters/index.ts new file mode 100644 index 0000000..44bb0aa --- /dev/null +++ b/src/formatters/index.ts @@ -0,0 +1 @@ +export * from './path'; diff --git a/src/formatters/path.ts b/src/formatters/path.ts new file mode 100644 index 0000000..bc9429f --- /dev/null +++ b/src/formatters/path.ts @@ -0,0 +1,79 @@ +const DEFAULT_CONFIG = { + trailingSlashes: false, + leadingSlashes: false, +} satisfies FormatterConfig; + +type SlashConfig = 'single' | true | false; + +export interface FormatterConfig { + trailingSlashes?: SlashConfig; + leadingSlashes?: SlashConfig; +} + +export class PathFormatter { + public static format(path: string, config: FormatterConfig = DEFAULT_CONFIG): string { + // Trailing Slashes + path = PathFormatter.formatTrailingSlashes(path, config.trailingSlashes); + + // Leading Slashes + path = PathFormatter.formatLeadingSlashes(path, config.leadingSlashes); + + return path; + } + + public static formatTrailingSlashes( + path: string, + config: SlashConfig = DEFAULT_CONFIG.trailingSlashes + ): string { + // Single means making sure there is exactly one trailing slash + if (config === 'single') { + return PathFormatter.ensureSingleTrailingSlash(path); + } + + // False means removing all trailing slashes + else if (!config) { + return PathFormatter.removeTrailingSlashes(path); + } + + // False or anything else + else { + return path; + } + } + + public static removeTrailingSlashes(path: string) { + return path.replace(/\/+$/, ''); + } + + public static ensureSingleTrailingSlash(path: string) { + return `${this.removeTrailingSlashes(path)}/`; + } + + public static formatLeadingSlashes( + path: string, + config: SlashConfig = DEFAULT_CONFIG.leadingSlashes + ): string { + // Single means making sure there is exactly one leading slash + if (config === 'single') { + return PathFormatter.ensureSingleLeadingSlash(path); + } + + // False means removing all leading slashes + else if (!config) { + return PathFormatter.removeLeadingSlashes(path); + } + + // False or anything else + else { + return path; + } + } + + public static removeLeadingSlashes(path: string) { + return path.replace(/^\/+/, ''); + } + + public static ensureSingleLeadingSlash(path: string) { + return `/${this.removeLeadingSlashes(path)}`; + } +} diff --git a/src/http/client.ts b/src/http/client.ts index a87f28b..8fab3c2 100644 --- a/src/http/client.ts +++ b/src/http/client.ts @@ -1,6 +1,5 @@ import createDebug from 'debug'; -import { AuthManager } from '../auth'; import { HTTPAuthorizationError, HTTPBadRequestError, @@ -10,41 +9,55 @@ import { HTTPNotFoundError, HTTPTimeoutError, } from '../errors'; +import { PathFormatter } from '../formatters'; import { RequestHelper } from '../utilities'; import { URLValidator } from '../validators'; -import { StatusCode } from './constants'; +import { DEFAULT_HTTP_TIMEOUT_MS, StatusCode } from './constants'; +import { HttpInterceptorManager } from './interceptor-manager'; + +import type { HttpClientConfig, InterceptorManagerMap } from './types'; const debug = createDebug('sdk:http'); /** * Strapi SDK's HTTP Client * - * Provides methods for configuring the base URL, authentication strategies, - * and for performing HTTP requests with automatic header management and URL validation. + * Provides methods for configuring the base URL, timeout, interceptors, headers, + * and for performing HTTP requests with automatic URL validation. */ export class HttpClient { // Properties + public readonly interceptors: InterceptorManagerMap; + private _baseURL: string; + private _timeout: number; + + private readonly _headers: Record; // Dependencies - private readonly _authManager: AuthManager; private readonly _urlValidator: URLValidator; constructor( // Properties - baseURL: string, + config: HttpClientConfig, // Dependencies - authManager = new AuthManager(), urlValidator: URLValidator = new URLValidator() ) { - debug('initializing new client with base url: %o', baseURL); + debug('initializing new client with base url: %o', config.baseURL); // Initialization - this._baseURL = baseURL; + this._baseURL = PathFormatter.format(config.baseURL, { trailingSlashes: false }); + this._timeout = config.timeout ?? DEFAULT_HTTP_TIMEOUT_MS; + this._headers = config.headers ?? {}; + + // Initialize the global interceptors + this.interceptors = { + request: new HttpInterceptorManager(), + response: new HttpInterceptorManager(), + }; - this._authManager = authManager; this._urlValidator = urlValidator; // Validation @@ -56,10 +69,28 @@ export class HttpClient { * * @returns The base URL used for HTTP requests. */ - get baseURL(): string { + public get baseURL(): string { return this._baseURL; } + /** + * Gets the request timeout. + * + * @returns The timeout used for HTTP requests. + */ + public get timeout(): number { + return this._timeout; + } + + /** + * Gets the request headers. + * + * @returns The headers used for HTTP requests. + */ + public get headers(): Record { + return this._headers; + } + /** * Sets a new base URL for the HTTP client and validates it. * @@ -74,160 +105,349 @@ export class HttpClient { * * client.setBaseURL('http://newexample.com'); */ - setBaseURL(url: string): this { - debug('setting new base url: %o', url); - + public setBaseURL(url: string): this { this._urlValidator.validate(url); - this._baseURL = url; + // Make sure the base URL don't have trailing slashes + this._baseURL = PathFormatter.format(url, { trailingSlashes: false }); + + debug('setting new base url: %o', this._baseURL); return this; } /** - * Sets the authentication strategy for the HTTP client. - * - * Configures how the client handles authentication based on the specified strategy and options. + * Sets a new timeout value for the HTTP client and validates it. * - * @param strategy - The authentication strategy to use. - * @param options - Additional options required for the authentication strategy. - * - * @throws {StrapiSDKError} If the given strategy is not supported + * @param timeout The new timeout to set. * * @returns The HttpClient instance for chaining. * + * @throws {TypeError} If the timeout is not a safe integer. + * * @example - * client.setAuthStrategy('api-token', { token: 'abc123' }); + * const client = new HttpClient({ baseURL: 'http://example.com' }); + * + * client.setTimeout(3000); */ - setAuthStrategy(strategy: string, options: unknown): this { - debug('setting auth strategy to %o', strategy); + public setTimeout(timeout: number): this { + debug('setting new timeout: %o', timeout); + + if (!Number.isSafeInteger(timeout)) { + throw new TypeError('Timeout must be a safe integer'); + } - this._authManager.setStrategy(strategy, options); + this._timeout = timeout; return this; } /** - * Performs an HTTP fetch request to the specified URL. + * Sends an HTTP request to the specified relative path with the provided options, applying interceptors, + * global headers, and a timeout mechanism. * - * Attaches the necessary headers, authenticates if required, and handles unauthorized errors. + * The `request` method handles low-level HTTP transactions and should be used + * internally or via helper methods (`get`, `post`, `put`, `delete`). * - * @param path - The path to which the request is made, appended to the base URL. - * @param [init] - Optional object containing any custom settings to apply to the fetch request. + * @param path - The relative URL (path) to use for the HTTP request. + * This shouldn't include the base URL as it is automatically appended. + * @param [init] - (Optional) The request initialization options, following the `RequestInit` interface. + * This can include headers, method, body, signal, and other fetch options. * - * @returns A promise that resolves to the HTTP response. + * @returns A `Promise` resolving with the HTTP response after being processed by response interceptors. + * The response contains the server's reply to the HTTP call, which consumers can handle as needed. * - * @throws {Error} If the authentication can't be completed, or if the server can't be reached + * @throws {DOMException} If the request is aborted due to exceeding the timeout limit. * * @example - * client.fetch('/data') - * .then(response => response.json()) - * .then(data => console.log(data)); + * // Example usage of the request method to send a GET request: + * const client = new HttpClient({ baseURL: 'https://api.example.com', timeout: 5000 }); + * + * try { + * const response = await client.request('/users', { method: 'GET' }); + * const data = await response.json(); + * console.log(data); + * } catch (error) { + * console.error('Request failed:', error); + * } + * + * @example + * // Sending a POST request with a JSON payload: + * const response = await client.request('/users', { + * method: 'POST', + * headers: { 'Content-Type': 'application/json' }, + * body: JSON.stringify({ name: 'John Doe', email: 'john@example.com' }), + * }); + * console.log(await response.json()); + * + * @example + * // Handling timeout: + * try { + * const response = await client.request('/slow-endpoint', { method: 'GET' }); + * console.log(await response.text()); + * } catch (error) { + * if (error.name === 'AbortError') { + * console.error('Request aborted due to timeout.'); + * } else { + * console.error('Request failed:', error); + * } + * } + * + * @additional-information + * - **Global Headers**: The method appends headers defined in the `_headers` property of the `HttpClient`. + * - **Interceptors**: Request and response interceptors are defined in the `interceptors` property + * and are executed to modify or handle request and response lifecycle logic. + * - **Timeout**: The timeout duration is specified when configuring the `HttpClient` instance. It overrides + * any abort signals provided by the user. + * - **Dependencies**: + * - Relies on the `fetch` API to execute the HTTP request. + * - `PathFormatter` is used to sanitize the relative path. + * + * @see {@link HttpClient.interceptors} for adding custom interceptors. + * @see {@link HttpClient.baseURL} for setting the base URL of requests. + * @see {@link PathFormatter.format} for formatting and sanitizing URL paths. */ - async fetch(path: string, init?: RequestInit): Promise { - const url = new URL(`${this._baseURL}${path}`); - const request = new Request(url, init); - - debug('performing a fetch request to %o', RequestHelper.format(request)); + public async request(path: string, init?: RequestInit): Promise { + const safePath = PathFormatter.format(path, { leadingSlashes: 'single' }); - const { strategy, isAuthenticated } = this._authManager; + const url = new URL(`${this.baseURL}${safePath}`); + const originalRequest = new Request(url, init); - if (strategy && !isAuthenticated) { - debug( - 'an auth strategy is set (%o), but the client is not authenticated, trying to authenticate now', - strategy - ); - await this._authManager.authenticate(this); + // Attach instance headers + for (const [key, value] of Object.entries(this._headers)) { + originalRequest.headers.append(key, value); + debug('%o header set to %o for %o', key, value, RequestHelper.format(originalRequest)); } - this.attachHeaders(request); + // Apply global request interceptors + const { request: processedRequest } = await this.interceptors.request.execute({ + request: originalRequest, + }); + + // Create a custom controller to stop the request if it exceeds the timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this._timeout); + + // Create a request that can be aborted using the custom controller, + // this always supersedes user-defined signals in interceptors + const request = new Request(processedRequest, { signal: controller.signal }); try { - return await this._fetch(request); - } catch (e) { - this.handleFetchError(e); + // Make the network call + const response = await this.fetch(url, request); + + clearTimeout(timeoutId); - throw e; + // Process the response using the response interceptors + const { response: processedResponse } = await this.interceptors.response.execute({ + request, + response, + }); + + return processedResponse; + } catch (error) { + // Propagate errors through response interceptors + clearTimeout(timeoutId); + + throw await this.interceptors.response.reject(error); } } /** - * Handles HTTP fetch error logic. + * Makes a direct HTTP request to the specified URL or request input using the Fetch API. + * + * This method provides a low-level interface to send HTTP requests using the global Fetch API. + * It is protected and intended to be used internally by the `HttpClient` class. * - * It deals with unauthorized responses by delegating the handling of the error to the authentication manager. + * @param input - The target URL or a `RequestInfo` instance representing the request to be made. + * @param [init] - (Optional) Configuration options for the request, compatible with the `RequestInit` interface. * - * @param error - The original HTTP request object that encountered an error. Used for error handling. + * @returns A `Promise` that resolves with the raw HTTP Response returned by the server. * - * @see {@link AuthManager#handleUnauthorizedError} for handling unauthorized responses. + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API Fetch API Documentation} */ - private handleFetchError(error: unknown) { - if (error instanceof HTTPAuthorizationError) { - debug('received an authorization error, delegating to the auth manager for handling'); - this._authManager.handleUnauthorizedError(); - } + protected fetch(input: RequestInfo | URL, init?: RequestInit): Promise { + return globalThis.fetch(input, init); } /** - * Executes an HTTP fetch request using the Fetch API. + * Sends an HTTP GET request to the specified relative path. + * + * This method is a shortcut for making GET requests with the `request` method. * - * @param input - The target of the HTTP request which can be a string URL or a `Request` object. - * @param [init] - An optional `RequestInit` object that contains any custom settings that you want to apply to the request. + * @param path - The relative URL (path) to send the request to. + * The base URL is automatically appended. * - * @returns A promise that resolves to the `Response` object representing the complete HTTP response. + * @param [init] - (Optional) Additional request options such as headers or signals. * - * @throws {HTTPError} if the request fails + * @returns A `Promise` that resolves to the HTTP Response. + * It contains the server's reply to the GET request. * - * @additionalInfo - * - This method doesn't perform any authentication or header customization. - * It directly passes the parameters to the global `fetch` function. - * - To include authentication, consider using the `fetch` method from the `HttpClient` class, which handles headers and authentication. + * @example + * // Example usage of the get method: + * const client = new HttpClient({ baseURL: 'https://api.example.com' }); + * const response = await client.get('/users'); + * const data = await response.json(); + * console.log(data); */ - async _fetch(input: RequestInfo, init?: RequestInit): Promise { - const request = new Request(input, init); - - debug('performing an internal fetch request to %o', RequestHelper.format(request)); - - const response = await globalThis.fetch(request); - - if (!response.ok) { - const { status, statusText } = response; - - debug( - 'server responded to %o with an error status: %o, reason: %o', - RequestHelper.format(request), - status, - statusText - ); - - throw this.mapResponseToHTTPError(response, request); - } + public async get(path: string, init?: RequestInit): Promise { + return this.request(path, { method: 'GET', ...init }); + } - return response; + /** + * Sends an HTTP POST request to the specified relative path with an optional request body. + * + * This method is a shortcut for making POST requests with the `request` method. + * + * @param path - The relative URL (path) to send the request to. + * The base URL is automatically appended. + * + * @param [body] - (Optional) The content to be sent in the request body, such as JSON or form data. + * + * @param [init] - (Optional) Additional request options such as headers or signals. + * + * @returns A `Promise` that resolves to the HTTP Response. + * It contains the server's reply to the POST request. + * + * @example + * // Example usage of the post method: + * const client = new HttpClient({ + * baseURL: 'https://api.example.com', + * headers: { 'Content-Type': 'application/json' } + * }); + * + * const response = await client.post('/users', JSON.stringify({ name: 'John' })); + * const data = await response.json(); + * + * console.log(data); + */ + public async post(path: string, body?: BodyInit, init?: RequestInit): Promise { + return this.request(path, { method: 'POST', body, ...init }); } /** - * Attaches default and authentication headers to an HTTP request. + * Sends an HTTP PUT request to the specified relative path with an optional request body. + * + * This method is a shortcut for making PUT requests with the `request` method. * - * This method ensures that a default 'Content-Type' header is set for the request if it is not already specified. + * @param path - The relative URL (path) to send the request to. The base URL is automatically appended. + * @param [body] - (Optional) The content to be sent in the request body, such as JSON or form data. + * @param [init] - (Optional) Additional request options such as headers or signals. + * + * @returns A `Promise` that resolves to the HTTP Response. It contains the server's reply to the PUT request. + * + * @example + * // Example usage of the put method: + * const client = new HttpClient({ + * baseURL: 'https://api.example.com', + * headers: { 'Content-Type': 'application/json' } + * }); * - * It also delegates to the AuthManager to append any necessary authentication headers, - * potentially overwriting existing ones to ensure correct authorization. + * const response = await client.put('/users/1', JSON.stringify({ name: 'John Updated' })); + * const data = await response.json(); * - * @param request - The HTTP request object to which headers are added. + * console.log(data); */ - private attachHeaders(request: Request) { - this.setContentTypeHeader(request); + public async put(path: string, body?: BodyInit, init?: RequestInit): Promise { + return this.request(path, { method: 'PUT', body, ...init }); + } - // Set auth headers if available, potentially overwrite manually set auth headers - this._authManager.authenticateRequest(request); + /** + * Sends an HTTP DELETE request to the specified relative path. + * + * This method is a shortcut for making DELETE requests with the `request` method. + * + * @param path - The relative URL (path) to send the request to. + * The base URL is automatically appended. + * + * @param [init] - (Optional) Additional request options such as headers or signals. + * + * @returns A `Promise` that resolves to the HTTP Response. + * It contains the server's reply to the DELETE request. + * + * @example + * // Example usage of the delete method: + * const client = new HttpClient({ baseURL: 'https://api.example.com' }); + * + * const response = await client.delete('/users/1'); + * if (response.ok) { + * console.log('User deleted successfully.'); + * } else { + * console.error('Failed to delete user.'); + * } + */ + public async delete(path: string, init?: RequestInit): Promise { + return this.request(path, { method: 'DELETE', ...init }); } - private setContentTypeHeader(request: Request) { - const [key, value] = ['Content-Type', 'application/json']; + /** + * Creates a new instance of `HttpClient`, inheriting the current instance's configuration + * with the option to inherit request and response interceptors. + * + * This method is designed to enable the creation of a modified or isolated `HttpClient` instance + * that preserves the prototype chain, allowing better extensibility for subclasses or testing setups. + * + * Defaults to inheriting all interceptors unless specified otherwise. + * + * @param [config={}] - An optional configuration object to override the client's existing settings. + * If a property is not provided, it falls back to the current instance's configuration. + * + * @param [inheritInterceptors=true] - Whether to inherit the current instance's request and response interceptors. + * If `false`, the new instance won't include any interceptors. + * + * @returns A new `HttpClient` instance with either the inherited or overridden configuration and interceptors. + * + * @throws {Error} If the created instance is not an instance of `HttpClient` (unexpected scenario). + * + * @example + * // Creating a new client with default settings + * const newClient = httpClient.create(); + * + * // Creating a new client with custom settings and no inherited interceptors + * const customClient = httpClient.create({ baseURL: 'https://api.example.com' }, false); + * + * @example + * // Using the new instance for isolated requests + * const client = httpClient.create({ headers: { Authorization: 'Bearer token' } }); + * const response = await client.get('/data'); + * + * @see {@link HttpClientConfig} for details on valid configuration options. + */ + public create( + config: Partial = {}, + inheritInterceptors: boolean = true + ): HttpClient { + // Object.getPrototypeOf is used here to dynamically retrieve the prototype of the current instance, + // allowing to invoke the constructor dynamically without explicitly tying the code to the HttpClient class. + // + // This provides flexibility and ensures that any subclass of HttpClient + // that extends its prototype can also use the `create` method properly without being + // overridden by a hard-coded reference to HttpClient itself. + // + // Essentially, it enables inheritance and thus makes mocking the HttpClient easier + const proto = Object.getPrototypeOf(this); + + const fork = new proto.constructor( + { + baseURL: config.baseURL ?? this._baseURL, + timeout: config.timeout ?? this._timeout, + headers: config.headers ?? this._headers, + }, + // Keep the same dependencies reference + this._urlValidator + ); + + // Unlikely, but this prevents prototype replacement and allows type narrowing as a side effect + if (!(fork instanceof HttpClient)) { + throw new Error('The created instance is not an instance of HttpClient'); + } - request.headers.set(key, value); + if (inheritInterceptors) { + fork.interceptors.request = this.interceptors.request.clone(); + fork.interceptors.response = this.interceptors.response.clone(); + } - debug('%o header set to %o for %o', key, value, RequestHelper.format(request)); + return fork; } /** @@ -243,7 +463,7 @@ export class HttpClient { * * @see {@link StatusCode} for all possible HTTP status codes and their meanings. */ - private mapResponseToHTTPError(response: Response, request: Request): HTTPError { + static mapResponseToHTTPError(response: Response, request: Request): HTTPError { switch (response.status) { case StatusCode.BAD_REQUEST: return new HTTPBadRequestError(response, request); diff --git a/src/http/constants.ts b/src/http/constants.ts index 79b77bd..43f4f04 100644 --- a/src/http/constants.ts +++ b/src/http/constants.ts @@ -1,11 +1,80 @@ export enum StatusCode { + /** + * Standard response for successful HTTP requests. + * + * The actual response depends on the request method used. + * + * - In a GET request, the response contains an entity corresponding to the requested resource. + * - In a POST request, the response contains an entity describing or containing the result of the action + */ OK = 200, + + /** + * The request has been fulfilled, resulting in the creation of a new resource + */ CREATED = 201, + + /** + * The server successfully processed the request and is not returning any content + */ NO_CONTENT = 204, + + /** + * The server can't or won't process the request due to a client error. + * + * Possible reasons are: + * - malformed request syntax + * - size too large + * - invalid request message framing + * - deceptive request routing + */ BAD_REQUEST = 400, + + /** + * Similar to 403 Forbidden, but specifically for use when authentication + * is required and has failed or has not yet been provided. + * + * The '401' status semantically means "unauthenticated", the user doesn't + * have valid authentication credentials for the target resource. + */ UNAUTHORIZED = 401, + + /** + * The request contained valid data and was understood by the server, but the server is refusing action. + * + * This may be due to the user not having the necessary permissions for a resource or needing an account of some sort, + * or attempting a prohibited action + * + * The request shouldn't be repeated. + */ FORBIDDEN = 403, + + /** + * The requested resource couldn't be found but may be available in the future. + * + * Subsequent requests by the client are permissible. + */ NOT_FOUND = 404, + + /** + * The server timed out waiting for the request. + * + * According to HTTP specifications: + * + * "The client didn't produce a request within the time that the server was prepared to wait. + * The client MAY repeat the request without modifications at any later time." + */ TIMEOUT = 408, + + /** + * A generic error message, given when an unexpected condition was encountered and no more specific message is suitable. + */ INTERNAL_SERVER_ERROR = 500, } + +/** + * Default timeout value in milliseconds for HTTP requests. + * + * It is set to 10.000 ms (10 seconds) and can be used as a baseline for setting request timeouts. + */ +export const DEFAULT_HTTP_TIMEOUT_MS = 10000; diff --git a/src/http/index.ts b/src/http/index.ts index 217d867..b1ab283 100644 --- a/src/http/index.ts +++ b/src/http/index.ts @@ -1,2 +1,3 @@ export * from './client'; export * from './constants'; +export type * from './types'; diff --git a/src/http/interceptor-manager.ts b/src/http/interceptor-manager.ts new file mode 100644 index 0000000..f4bf736 --- /dev/null +++ b/src/http/interceptor-manager.ts @@ -0,0 +1,158 @@ +export type Interceptor = (value: T) => Promise | T; + +export interface Handler { + fulfilled?: Interceptor; + rejected?: Interceptor; +} + +export type Handlers = Handler[]; + +/** + * A utility class for managing and executing a series of interceptors on a + * provided value, such as requests or responses, in an HTTP client. + * + * @template T - The type of the value to be processed by the interceptors. + * + * @example + * // Creating an instance of HttpInterceptorManager + * const requestInterceptors = new HttpInterceptorManager<{ request: Request }>(); + * + * // Adding interceptors + * requestInterceptors.use(async (value) => { + * console.log('Processing request: ', value); + * return value; // Required to continue the chain + * }, async (error) => { + * console.error('Handling request error:', error); + * throw error; // If not re-thrown, the chain continues + * }); + * + * // Executing interceptors + * const processedValue = await requestInterceptors.execute({ request: new Request('http://example.com') }); + */ +export class HttpInterceptorManager { + private readonly _handlers: Handlers; + + constructor(handlers: Handlers = []) { + this._handlers = handlers; + } + + /** + * Registers a new fulfilled and/or rejected interceptor to the handler chain. + * + * @param fulfilled - A function to process the input value when the operation succeeds. + * This is required and must return the value or a transformed version of it. + * + * @param [rejected] - (Optional) A function to handle errors that may occur in the chain. + * This function should either throw an error or return a transformed value. + * + * @returns The current instance of `HttpInterceptorManager` for method chaining. + * + * @example + * const manager = new HttpInterceptorManager(); + * + * manager.use( + * async (value) => { + * console.log('Processing:', value); + * return value; + * }, + * async (error) => { + * console.error('Error:', error); + * return error; + * } + * ); + */ + public use(fulfilled?: Interceptor, rejected?: Interceptor): this { + this._handlers.push({ fulfilled, rejected }); + return this; + } + + /** + * Creates a deep clone of the current `HttpInterceptorManager` instance, + * including all currently registered interceptors. + * + * @returns A new `HttpInterceptorManager` instance with identical interceptors in the same order. + * + * @example + * const originalManager = new HttpInterceptorManager(); + * const clonedManager = originalManager.clone(); + */ + public clone(): HttpInterceptorManager { + return new HttpInterceptorManager([...this._handlers]); + } + + /** + * Executes the registered interceptors sequentially, processing the provided + * input value through the fulfilled and optional rejected interceptors. + * + * If any fulfilled interceptor throws an error and a corresponding rejected + * interceptor is defined, the rejected interceptor handles the error. + * Otherwise, the error propagates up the chain. + * + * @param value - The initial value to be processed through the interceptor chain. + * + * @returns A promise resolving to the processed value after all interceptors have been executed. + * + * @throws - Any error that is not handled by a rejected interceptor is re-thrown. + * + * @example + * const interceptors = new HttpInterceptorManager<{ request: Request }>(); + * + * interceptors.use(async (value) => { + * value.request.headers.set('Authorization', 'Bearer token'); + * return value; + * }); + * + * const result = await interceptors.execute({ request: new Request('http://example.com') }); + */ + public async execute(value: T): Promise { + let out = value; + + for (const handler of this._handlers) { + try { + if (handler.fulfilled) { + out = await handler.fulfilled(out); + } + } catch (error) { + if (handler.rejected) { + out = await handler.rejected(error); + } else { + throw error; + } + } + } + return out; + } + + /** + * Executes only the rejected interceptors sequentially with an initial error value. + * + * This method is useful for propagating errors through a chain of rejection handlers. + * + * @param error - The initial error to be processed by the rejected interceptors. + * + * @returns A promise resolving to the final processed error value, which may be transformed + * by the rejection handlers. + * + * @example + * const interceptors = new HttpInterceptorManager(); + * + * interceptors.use( + * null, + * async (error) => { + * console.log('Handling error:', error); + * return { handled: true }; + * } + * ); + * + * const result = await interceptors.reject(new Error('Sample error')); + */ + public async reject(error: unknown): Promise { + let out = error; + for (const handler of this._handlers) { + if (handler.rejected) { + out = await handler.rejected(out); + } + } + return out; + } +} diff --git a/src/http/types.ts b/src/http/types.ts new file mode 100644 index 0000000..628c756 --- /dev/null +++ b/src/http/types.ts @@ -0,0 +1,31 @@ +import { HttpInterceptorManager } from './interceptor-manager'; + +import type { Interceptor } from './interceptor-manager'; + +export interface HttpClientConfig { + baseURL: string; + timeout?: number; + headers?: Record; +} + +// Payloads + +export type RequestInterceptorPayload = { request: Request }; +export type ResponseInterceptorPayload = { response: Response; request: Request }; + +// Interceptors + +export type RequestInterceptor = Interceptor; +export type ResponseInterceptor = Interceptor; + +// Interceptor Managers + +export type RequestInterceptorManager = HttpInterceptorManager; +export type ResponseInterceptorManager = HttpInterceptorManager; + +// MISC + +export interface InterceptorManagerMap { + request: RequestInterceptorManager; + response: ResponseInterceptorManager; +} diff --git a/src/interceptors/auth.ts b/src/interceptors/auth.ts new file mode 100644 index 0000000..2590520 --- /dev/null +++ b/src/interceptors/auth.ts @@ -0,0 +1,129 @@ +import { AuthManager } from '../auth'; +import { HTTPAuthorizationError } from '../errors'; +import { HttpClient, StatusCode } from '../http'; + +import type { RequestInterceptor, ResponseInterceptor } from '../http'; +import type { Interceptor } from '../http/interceptor-manager'; + +/** + * A utility class providing a set of HTTP interceptors for managing authentication lifecycle operations. + * + * It includes methods to: + * - Ensure pre-authentication before making any HTTP requests. + * - Automatically authenticate outgoing HTTP requests by injecting authentication headers. + * - Handle unauthorized HTTP responses and notify the authentication manager. + * + * This class is primarily used in combination with an {@link AuthManager} and an {@link HttpClient} to enable seamless + * integration of authentication capability within HTTP workflows. + */ +export class AuthInterceptors { + /** + * Ensures the user is pre-authenticated before an HTTP request is sent. + * + * This interceptor checks if the authentication manager has a configured strategy but the + * user is not yet authenticated. If so, it triggers the authentication process. + * + * @param authManager - The `AuthManager` instance that handles the authentication process. + * @param httpClient - The `HttpClient` instance used during the authentication process. + * + * @returns A request interceptor that ensures pre-authentication. + * + * @example + * ```typescript + * httpClient.interceptors.request.use( + * AuthInterceptors.ensurePreAuthentication(authManager, httpClient) + * ); + * ``` + * + * @throws - An error if the authentication process fails. + * + * @note The provided http client should **NOT** have auth interceptors attached to it as it might lead to infinite authentication loops + */ + public static ensurePreAuthentication( + authManager: AuthManager, + httpClient: HttpClient + ): RequestInterceptor { + return async ({ request }) => { + const { strategy, isAuthenticated } = authManager; + + if (strategy && !isAuthenticated) { + await authManager.authenticate(httpClient); + } + + return { request }; + }; + } + + /** + * Authenticates outgoing HTTP requests by injecting authentication-specific headers. + * + * This interceptor updates HTTP requests with the necessary authentication information, + * such as tokens, sourced from the current authentication provider. + * + * @param authManager - The `AuthManager` instance that manages request authentication. + * + * @returns A request interceptor that injects authentication headers into outgoing requests. + * + * @example + * ```typescript + * httpClient.interceptors.request.use( + * AuthInterceptors.authenticateRequests(authManager) + * ); + * ``` + * + * @throws - An error if the headers in the HTTP request are invalid or unavailable. + */ + public static authenticateRequests(authManager: AuthManager): RequestInterceptor { + return ({ request }) => { + authManager.authenticateRequest(request); + + return { request }; + }; + } + + /** + * Notifies the authentication manager upon receiving an unauthorized HTTP response. + * + * This interceptor detects `401 Unauthorized` errors in HTTP responses, indicating + * that the current authentication session has become invalid and resets the + * authentication state. + * + * @param authManager - The `AuthManager` instance that manages the authentication state and errors. + * + * @returns A response interceptor that handles unauthorized responses. + * + * @example + * ```typescript + * httpClient.interceptors.response.use( + * AuthInterceptors.notifyOnUnauthorizedResponse(authManager) + * ); + * ``` + */ + public static notifyOnUnauthorizedResponse( + authManager: AuthManager + ): [fulfillment: ResponseInterceptor, rejection: Interceptor] { + const notify = () => authManager.handleUnauthorizedError(); + + // Intercepts successful unauthorized requests and notifies the auth manager + const fulfillment: ResponseInterceptor = ({ request, response }) => { + const isUnauthorized = !response.ok && response.status === StatusCode.UNAUTHORIZED; + + if (isUnauthorized) { + notify(); + } + + return { request, response }; + }; + + // Intercepts HTTPAuthorizationError errors and notifies the auth manager + const rejection: Interceptor = (payload) => { + if (payload instanceof HTTPAuthorizationError) { + notify(); + } + + return payload; + }; + + return [fulfillment, rejection]; + } +} diff --git a/src/interceptors/http.ts b/src/interceptors/http.ts new file mode 100644 index 0000000..10fe8a7 --- /dev/null +++ b/src/interceptors/http.ts @@ -0,0 +1,75 @@ +import { HttpClient } from '../http'; + +import type { RequestInterceptor, ResponseInterceptor } from '../http'; + +/** + * A utility class to manage HTTP interceptors for requests and responses. + * + * This class provides static factory methods to create and register interceptors + * for HTTP clients, ensuring consistent capability like adding default headers + * to requests and transforming HTTP response errors into standardized exceptions. + * + * It is primarily used in conjunction with the {@link HttpClient} class to handle + * pre- and post-processing of HTTP requests and responses. + */ +export class HttpInterceptors { + /** + * Automatically sets default headers for all HTTP requests. + * + * This interceptor is typically used to attach headers such as `Content-Type`, or other app-specific metadata. + * + * @returns A request interceptor that modifies the request to include the default headers. + * + * @example + * ```typescript + * httpClient.interceptors.request.use(HttpInterceptors.setDefaultHeaders()); + *``` + */ + public static setDefaultHeaders(): RequestInterceptor { + return ({ request }) => { + request.headers.set('Content-Type', 'application/json'); + + return { request }; + }; + } + + /** + * Handle HTTP response errors and transform them into + * more specific and meaningful exceptions (subclasses of `HTTPError`) + * + * This interceptor looks at HTTP responses and checks whether it was successful. + * + * If the response indicates failure (non-OK status), it maps the response status to a + * specific `HTTPError` subclass. + * + * @returns A response interceptor that transforms errors into custom exceptions. + * + * @example + * ```typescript + * // Register error transformation in an HTTP client + * const httpClient = new HttpClient(config); + * httpClient.interceptors.response.use(HttpInterceptors.transformErrors()); + *``` + * + * @errors + * Throws a specific subclass of `HTTPError` if the response is not successful. + * - `HTTPBadRequestError` (400) + * - `HTTPAuthorizationError` (401) + * - `HTTPForbiddenError` (403) + * - `HTTPNotFoundError` (404) + * - `HTTPTimeoutError` (408) + * - `HTTPInternalServerError` (500) + * - 'HTTPError' (default) + * + * @see {@link HttpClient.mapResponseToHTTPError} + */ + public static transformErrors(): ResponseInterceptor { + return ({ request, response }) => { + if (response.ok) { + return { request, response }; + } + + throw HttpClient.mapResponseToHTTPError(response, request); + }; + } +} diff --git a/src/interceptors/index.ts b/src/interceptors/index.ts new file mode 100644 index 0000000..53d0b34 --- /dev/null +++ b/src/interceptors/index.ts @@ -0,0 +1,2 @@ +export * from './auth'; +export * from './http'; diff --git a/src/sdk.ts b/src/sdk.ts index 813be6e..5b20201 100644 --- a/src/sdk.ts +++ b/src/sdk.ts @@ -1,20 +1,34 @@ import createDebug from 'debug'; +import { AuthManager } from './auth'; import { CollectionTypeManager, SingleTypeManager } from './content-types'; import { StrapiSDKInitializationError } from './errors'; import { HttpClient } from './http'; +import { AuthInterceptors, HttpInterceptors } from './interceptors'; import { StrapiSDKValidator } from './validators'; +import type { HttpClientConfig } from './http'; + const debug = createDebug('sdk:core'); export interface StrapiSDKConfig { + /** The base URL of the Strapi content API, required for all SDK operations. */ baseURL: string; + + /** Optional authentication configuration, which specifies a strategy and its details. */ auth?: AuthConfig; } +/** + * Describes an authentication strategy used in the SDK configuration. + * + * @template T The type of options for the authentication strategy. + */ export interface AuthConfig { + /** The identifier of the authentication method */ strategy: string; - options: T; + /** Configuration details for the specified strategy */ + options?: T; } /** @@ -35,6 +49,9 @@ export class StrapiSDK /** @internal */ private readonly _validator: StrapiSDKValidator; + /** @internal */ + private readonly _authManager: AuthManager; + /** @internal */ private readonly _httpClient: HttpClient; @@ -45,11 +62,17 @@ export class StrapiSDK // Dependencies validator: StrapiSDKValidator = new StrapiSDKValidator(), - httpClientFactory?: (url: string) => HttpClient + authManager: AuthManager = new AuthManager(), + + // Lazy dependencies + httpClientFactory?: (config: HttpClientConfig) => HttpClient ) { // Properties this._config = config; + + // Dependencies this._validator = validator; + this._authManager = authManager; debug('started the initialization process'); @@ -61,7 +84,9 @@ export class StrapiSDK // The HTTP client depends on the preflightValidation for the baseURL validity. // It could be instantiated before but would throw an invalid URL error // instead of the SDK itself throwing an initialization exception. - this._httpClient = httpClientFactory?.(config.baseURL) ?? new HttpClient(config.baseURL); + this._httpClient = httpClientFactory + ? httpClientFactory({ baseURL: config.baseURL }) + : new HttpClient({ baseURL: config.baseURL }); this.init(); @@ -107,26 +132,75 @@ export class StrapiSDK /** * Initializes the configuration settings for the SDK. * - * Sets up the necessary parts required for the SDK's operation, - * including setting up an authentication strategy if provided. + * @internal + */ + private init() { + debug('init modules'); + + this.initHttp(); + this.initAuth(); + } + + /** + * Initializes the HTTP client configuration for the SDK. * - * @throws {StrapiSDKValidationError} From the _httpClient if the baseURL is invalid. + * Sets up necessary HTTP interceptors to ensure consistent behavior: + * - Adds default HTTP request headers. + * - Configures error handling for HTTP responses. + * + * It basically ensures that all outgoing HTTP requests include standard headers + * and that errors are properly converted into meaningful exceptions for easier debugging. * * @note - * - This method is private and internally invoked only during SDK initialization. - * - Although this method technically -can- throw a validation error, the baseURL - * should already have been validated during the SDK preflight validation. + * This method is private and should only be invoked internally during the SDK initialization process. * * @internal */ - private init() { + private initHttp() { + debug('init http module'); + + // Automatically sets default headers for all HTTP requests. + this._httpClient.interceptors.request.use(HttpInterceptors.setDefaultHeaders()); + + // Handle HTTP response errors and transform them into + // more specific and meaningful exceptions (subclasses of `HTTPError`) + this._httpClient.interceptors.response.use(HttpInterceptors.transformErrors()); + } + + /** + * Initializes the authentication configuration for the SDK. + * + * Sets up authentication strategies and required HTTP interceptors to: + * - Handle user authentication through the configured strategy. + * - Automatically attach authentication data (for example, tokens) to outgoing HTTP requests. + * - Handle authentication errors (for example, unauthorized responses) consistently. + * + * @note + * This method is private and should only be invoked internally during the SDK initialization process. + * + * @internal + */ + private initAuth() { + debug('init auth module'); + + // If an auth configuration is defined, use it to configure the auth manager if (this.auth) { const { strategy, options } = this.auth; - debug('setting up the http auth strategy using %o', strategy); + debug('setting up the auth strategy using %o', strategy); - this._httpClient.setAuthStrategy(strategy, options); + this._authManager.setStrategy(strategy, options); } + + this._httpClient.interceptors.request + // Ensures the "user" is pre-authenticated before an HTTP request is sent. + .use(AuthInterceptors.ensurePreAuthentication(this._authManager, this._httpClient)) + // Authenticates outgoing HTTP requests by injecting authentication-specific headers. + .use(AuthInterceptors.authenticateRequests(this._authManager)); + + this._httpClient.interceptors.response + // Notifies the authentication manager upon receiving an unauthorized HTTP response or error. + .use(...AuthInterceptors.notifyOnUnauthorizedResponse(this._authManager)); } /** @@ -188,7 +262,7 @@ export class StrapiSDK * - The base URL is prepended to the provided endpoint path. */ fetch(url: string, init?: RequestInit) { - return this._httpClient.fetch(url, init); + return this._httpClient.request(url, init); } /** diff --git a/tests/fixtures/http-error-associations.ts b/tests/fixtures/http-error-associations.ts new file mode 100644 index 0000000..56e1de9 --- /dev/null +++ b/tests/fixtures/http-error-associations.ts @@ -0,0 +1,23 @@ +import { + HTTPAuthorizationError, + HTTPBadRequestError, + HTTPError, + HTTPForbiddenError, + HTTPInternalServerError, + HTTPNotFoundError, + HTTPTimeoutError, +} from '../../src'; +import { StatusCode } from '../../src/http'; + +export const HTTP_ERROR_ASSOCIATIONS = [ + [{ status: StatusCode.BAD_REQUEST, statusText: 'Bad Request' }, HTTPBadRequestError], + [{ status: StatusCode.UNAUTHORIZED, statusText: 'Unauthorized' }, HTTPAuthorizationError], + [{ status: StatusCode.FORBIDDEN, statusText: 'Forbidden' }, HTTPForbiddenError], + [{ status: StatusCode.NOT_FOUND, statusText: 'Not Found' }, HTTPNotFoundError], + [{ status: StatusCode.TIMEOUT, statusText: 'Timeout' }, HTTPTimeoutError], + [ + { status: StatusCode.INTERNAL_SERVER_ERROR, statusText: 'Internal Server Error' }, + HTTPInternalServerError, + ], + [{ status: 599, statusText: 'Unknown Error' }, HTTPError], +] as const; diff --git a/tests/unit/auth/manager.test.ts b/tests/unit/auth/manager.test.ts index 4ed00ca..1d09688 100644 --- a/tests/unit/auth/manager.test.ts +++ b/tests/unit/auth/manager.test.ts @@ -2,7 +2,7 @@ import { ApiTokenAuthProvider, AuthManager, UsersPermissionsAuthProvider } from import { MockAuthProvider, MockAuthProviderFactory, MockHttpClient } from '../mocks'; describe('AuthManager', () => { - const mockHttpClient = new MockHttpClient('https://example.com'); + const mockHttpClient = new MockHttpClient({ baseURL: 'https://example.com' }); describe('Default Registered Strategies', () => { it.each([ @@ -110,17 +110,39 @@ describe('AuthManager', () => { expect(authManager.isAuthenticated).toBe(false); }); - it('should authenticate request correctly', () => { - // Arrange - const authManager = new AuthManager(new MockAuthProviderFactory()); - const mockRequest = new Request('https://example.com', { headers: new Headers() }); + describe('Authenticate Request', () => { + it.each([ + ['an Headers instance', new Headers()], + ['a string record', {}], + ['an array', []], + ])('should authenticate request correctly with headers initialized as %s', (_, headers) => { + // Arrange + const authManager = new AuthManager(new MockAuthProviderFactory()); + const mockRequest = new Request('https://example.com', { headers }); - authManager.setStrategy(MockAuthProvider.identifier, {}); + authManager.setStrategy(MockAuthProvider.identifier, {}); - // Act - authManager.authenticateRequest(mockRequest); + // Act + authManager.authenticateRequest(mockRequest); - // Assert - expect(mockRequest.headers.get('Authorization')).toBe('Bearer '); + // Assert + expect(mockRequest.headers.get('Authorization')).toBe('Bearer '); + }); + + it('should throw an error if the request headers are not a valid Headers instance', async () => { + // Arrange + const expectedError = new Error( + 'Invalid request headers, headers must be an instance of Headers but found "string"' + ); + const authManager = new AuthManager(new MockAuthProviderFactory()); + + authManager.setStrategy(MockAuthProvider.identifier, {}); + + // @ts-expect-error the "headers" value is purposefully invalid to make the request's authentication fail + const action = () => authManager.authenticateRequest({ headers: '' }); + + // Act & Assert + expect(action).toThrow(expectedError); + }); }); }); diff --git a/tests/unit/auth/providers/users-permissions.test.ts b/tests/unit/auth/providers/users-permissions.test.ts index 4eeb49a..12b168a 100644 --- a/tests/unit/auth/providers/users-permissions.test.ts +++ b/tests/unit/auth/providers/users-permissions.test.ts @@ -1,9 +1,8 @@ +import { HTTPBadRequestError, StrapiSDKValidationError } from '../../../../src'; import { UsersPermissionsAuthProvider, UsersPermissionsAuthProviderOptions, } from '../../../../src/auth'; -import { HTTPBadRequestError, StrapiSDKValidationError } from '../../../../src/errors'; -import { HttpClient } from '../../../../src/http'; import { MockHttpClient, mockRequest, mockResponse } from '../../mocks'; const FAKE_TOKEN = ''; @@ -13,13 +12,13 @@ const FAKE_VALID_CONFIG: UsersPermissionsAuthProviderOptions = { }; class ValidFakeHttpClient extends MockHttpClient { - async _fetch() { + async request() { return new Response(JSON.stringify({ jwt: FAKE_TOKEN }), { status: 200 }); } } -class FaultyFakeHttpClient extends HttpClient { - async _fetch(): Promise { +class FaultyFakeHttpClient extends MockHttpClient { + async request(): Promise { const response = mockResponse(400, 'Bad Request'); const request = mockRequest('GET', 'https://example.com'); @@ -28,8 +27,8 @@ class FaultyFakeHttpClient extends HttpClient { } describe('UsersPermissionsAuthProvider', () => { - const fakeHttpClient = new ValidFakeHttpClient('https://example.com'); - const faultyHttpClient = new FaultyFakeHttpClient('https://example.com'); + const fakeHttpClient = new ValidFakeHttpClient({ baseURL: 'https://example.com' }); + const faultyHttpClient = new FaultyFakeHttpClient({ baseURL: 'https://example.com' }); describe('Name', () => { it('should return the static provider name from the instance', () => { @@ -95,6 +94,8 @@ describe('UsersPermissionsAuthProvider', () => { const provider = new UsersPermissionsAuthProvider(FAKE_VALID_CONFIG); // Act & Assert + await provider.authenticate(fakeHttpClient); + await expect(provider.authenticate(fakeHttpClient)).resolves.not.toThrow(); }); @@ -109,7 +110,24 @@ describe('UsersPermissionsAuthProvider', () => { expect(provider.headers).toEqual({ Authorization: `Bearer ${FAKE_TOKEN}` }); }); - it('should throw if it fails to authenticate', async () => { + it('should throw an error if the request fails', async () => { + // Arrange + const client = fakeHttpClient.create(undefined, false); + const statusText = 'Internal Server Error'; + + const provider = new UsersPermissionsAuthProvider(FAKE_VALID_CONFIG); + + jest + .spyOn(client, 'request') + .mockImplementationOnce(() => + Promise.resolve(new Response(null, { status: 500, statusText })) + ); + + // Act & Assert + await expect(provider.authenticate(client)).rejects.toThrow(new Error(statusText)); + }); + + it('should propagate the error if it fails to authenticate', async () => { // Arrange const provider = new UsersPermissionsAuthProvider(FAKE_VALID_CONFIG); diff --git a/tests/unit/content-types/collection/collection-manager.test.ts b/tests/unit/content-types/collection/collection-manager.test.ts index 3e5d531..44bb46c 100644 --- a/tests/unit/content-types/collection/collection-manager.test.ts +++ b/tests/unit/content-types/collection/collection-manager.test.ts @@ -2,12 +2,12 @@ import { CollectionTypeManager } from '../../../../src/content-types'; import { MockHttpClient } from '../../mocks'; describe('CollectionTypeManager CRUD Methods', () => { - const mockHttpClient = new MockHttpClient('http://localhost:1337'); + const mockHttpClient = new MockHttpClient({ baseURL: 'http://localhost:1337' }); const collectionManager = new CollectionTypeManager('articles', mockHttpClient); beforeEach(() => { jest - .spyOn(MockHttpClient.prototype, '_fetch') + .spyOn(MockHttpClient.prototype, 'request') .mockImplementation(() => Promise.resolve( new Response(JSON.stringify({ data: { id: 1 }, meta: {} }), { status: 200 }) @@ -31,10 +31,10 @@ describe('CollectionTypeManager CRUD Methods', () => { // Arrange const expected = '/articles?locale=en&populate=author&fields%5B0%5D=title&fields%5B1%5D=description&filters%5Bpublished%5D=true&sort=createdAt%3Adesc&pagination%5Bpage%5D=1&pagination%5BpageSize%5D=10'; - const fetchSpy = jest.spyOn(MockHttpClient.prototype, 'fetch'); + const requestSpy = jest.spyOn(MockHttpClient.prototype, 'request'); jest - .spyOn(MockHttpClient.prototype, '_fetch') + .spyOn(MockHttpClient.prototype, 'request') .mockImplementationOnce(() => Promise.resolve( new Response(JSON.stringify({ data: [{ id: 1 }, { id: 2 }], meta: {} }), { status: 200 }) @@ -52,14 +52,14 @@ describe('CollectionTypeManager CRUD Methods', () => { }); // Assert - expect(fetchSpy).toHaveBeenCalledWith(expected, { method: 'GET' }); + expect(requestSpy).toHaveBeenCalledWith(expected, { method: 'GET' }); }); it('should fetch a single document with complex query params in findOne method', async () => { // Arrange const expected = '/articles/1?locale=en&populate=comments&fields%5B0%5D=title&fields%5B1%5D=content'; - const fetchSpy = jest.spyOn(MockHttpClient.prototype, 'fetch'); + const requestSpy = jest.spyOn(MockHttpClient.prototype, 'request'); // Act await collectionManager.findOne('1', { @@ -69,19 +69,19 @@ describe('CollectionTypeManager CRUD Methods', () => { }); // Assert - expect(fetchSpy).toHaveBeenCalledWith(expected, { method: 'GET' }); + expect(requestSpy).toHaveBeenCalledWith(expected, { method: 'GET' }); }); it('should create a new document with create method', async () => { // Arrange const payload = { title: 'New Article' }; - const fetchSpy = jest.spyOn(MockHttpClient.prototype, 'fetch'); + const requestSpy = jest.spyOn(MockHttpClient.prototype, 'request'); // Act await collectionManager.create(payload, { locale: 'en' }); // Assert - expect(fetchSpy).toHaveBeenCalledWith('/articles?locale=en', { + expect(requestSpy).toHaveBeenCalledWith('/articles?locale=en', { method: 'POST', body: JSON.stringify({ data: payload }), }); @@ -90,13 +90,13 @@ describe('CollectionTypeManager CRUD Methods', () => { it('should update an existing document with update method', async () => { // Arrange const payload = { title: 'Updated Title' }; - const fetchSpy = jest.spyOn(MockHttpClient.prototype, 'fetch'); + const requestSpy = jest.spyOn(MockHttpClient.prototype, 'request'); // Act await collectionManager.update('1', payload, { locale: 'en' }); // Assert - expect(fetchSpy).toHaveBeenCalledWith('/articles/1?locale=en', { + expect(requestSpy).toHaveBeenCalledWith('/articles/1?locale=en', { method: 'PUT', body: JSON.stringify({ data: payload }), }); @@ -104,12 +104,12 @@ describe('CollectionTypeManager CRUD Methods', () => { it('should delete a document with delete method', async () => { // Arrange - const fetchSpy = jest.spyOn(MockHttpClient.prototype, 'fetch'); + const requestSpy = jest.spyOn(MockHttpClient.prototype, 'request'); // Act await collectionManager.delete('1', { locale: 'en' }); // Assert - expect(fetchSpy).toHaveBeenCalledWith('/articles/1?locale=en', { method: 'DELETE' }); + expect(requestSpy).toHaveBeenCalledWith('/articles/1?locale=en', { method: 'DELETE' }); }); }); diff --git a/tests/unit/content-types/collection/single-manager.test.ts b/tests/unit/content-types/collection/single-manager.test.ts index a79005c..d868710 100644 --- a/tests/unit/content-types/collection/single-manager.test.ts +++ b/tests/unit/content-types/collection/single-manager.test.ts @@ -2,14 +2,14 @@ import { SingleTypeManager } from '../../../../src/content-types'; import { MockHttpClient } from '../../mocks'; describe('SingleTypeManager CRUD Methods', () => { - const mockHttpClientFactory = (url: string) => new MockHttpClient(url); + const mockHttpClientFactory = (url: string) => new MockHttpClient({ baseURL: url }); const config = { baseURL: 'http://localhost:1337/api' }; const httpClient = mockHttpClientFactory(config.baseURL); const singleTypeManager = new SingleTypeManager('homepage', httpClient); beforeEach(() => { jest - .spyOn(MockHttpClient.prototype, '_fetch') + .spyOn(MockHttpClient.prototype, 'request') .mockImplementation(() => Promise.resolve( new Response(JSON.stringify({ data: { id: 1 }, meta: {} }), { status: 200 }) @@ -31,7 +31,7 @@ describe('SingleTypeManager CRUD Methods', () => { // Arrange const expected = '/homepage?locale=en&populate=sections&fields%5B0%5D=title&fields%5B1%5D=content'; - const fetchSpy = jest.spyOn(MockHttpClient.prototype, 'fetch'); + const requestSpy = jest.spyOn(MockHttpClient.prototype, 'request'); // Act await singleTypeManager.find({ @@ -41,19 +41,19 @@ describe('SingleTypeManager CRUD Methods', () => { }); // Assert - expect(fetchSpy).toHaveBeenCalledWith(expected, { method: 'GET' }); + expect(requestSpy).toHaveBeenCalledWith(expected, { method: 'GET' }); }); it('should update an existing document with update method', async () => { // Arrange const payload = { title: 'Updated Title' }; - const fetchSpy = jest.spyOn(MockHttpClient.prototype, 'fetch'); + const requestSpy = jest.spyOn(MockHttpClient.prototype, 'request'); // Act await singleTypeManager.update(payload, { locale: 'en' }); // Assert - expect(fetchSpy).toHaveBeenCalledWith('/homepage?locale=en', { + expect(requestSpy).toHaveBeenCalledWith('/homepage?locale=en', { method: 'PUT', body: JSON.stringify({ data: payload }), }); @@ -61,12 +61,12 @@ describe('SingleTypeManager CRUD Methods', () => { it('should delete a document with delete method', async () => { // Arrange - const fetchSpy = jest.spyOn(MockHttpClient.prototype, 'fetch'); + const requestSpy = jest.spyOn(MockHttpClient.prototype, 'request'); // Act await singleTypeManager.delete({ locale: 'en' }); // Assert - expect(fetchSpy).toHaveBeenCalledWith('/homepage?locale=en', { method: 'DELETE' }); + expect(requestSpy).toHaveBeenCalledWith('/homepage?locale=en', { method: 'DELETE' }); }); }); diff --git a/tests/unit/formatters/path.test.ts b/tests/unit/formatters/path.test.ts new file mode 100644 index 0000000..403a0fc --- /dev/null +++ b/tests/unit/formatters/path.test.ts @@ -0,0 +1,166 @@ +import { PathFormatter } from '../../../src/formatters'; + +import type { FormatterConfig } from '../../../src/formatters'; + +describe('PathFormatter', () => { + describe('format', () => { + it('should format both leading and trailing slashes according to the config', () => { + // Arrange + const config = { leadingSlashes: 'single', trailingSlashes: false } satisfies FormatterConfig; + + // Act + const result = PathFormatter.format('///path///', config); + + // Assert + expect(result).toBe('/path'); + }); + + it('should use default config when no config is provided', () => { + // Act + const result = PathFormatter.format('///path///'); + + // Assert + expect(result).toEqual('path'); + }); + }); + + describe('formatTrailingSlashes', () => { + it('should remove all trailing slashes when config is false', () => { + // Act + const result = PathFormatter.formatTrailingSlashes('path////', false); + + // Assert + expect(result).toBe('path'); + }); + + it('should ensure a single trailing slash when config is "single"', () => { + // Act + const result = PathFormatter.formatTrailingSlashes('path////', 'single'); + + // Assert + expect(result).toBe('path/'); + }); + + it('should leave the path unchanged when config is true', () => { + // Act + const result = PathFormatter.formatTrailingSlashes('path////', true); + + // Assert + expect(result).toBe('path////'); + }); + + it('should respect the config default when no config is passed', () => { + // Act + const result = PathFormatter.formatTrailingSlashes('path///'); + + // Assert + expect(result).toBe('path'); + }); + }); + + describe('formatLeadingSlashes', () => { + it('should remove all leading slashes when config is false', () => { + // Act + const result = PathFormatter.formatLeadingSlashes('////path', false); + + // Assert + expect(result).toBe('path'); + }); + + it('should ensure a single leading slash when config is "single"', () => { + // Act + const result = PathFormatter.formatLeadingSlashes('////path', 'single'); + + // Assert + expect(result).toBe('/path'); + }); + + it('should leave the path unchanged when config is true', () => { + // Act + const result = PathFormatter.formatLeadingSlashes('////path', true); + + // Assert + expect(result).toBe('////path'); + }); + + it('should respect the config default when no config is passed', () => { + // Act + const result = PathFormatter.formatLeadingSlashes('path'); + + // Assert + expect(result).toBe('path'); + }); + }); + + describe('removeTrailingSlashes', () => { + it('should remove all trailing slashes from the path', () => { + // Act + const result = PathFormatter.removeTrailingSlashes('path///'); + + // Assert + expect(result).toBe('path'); + }); + + it('should return the path unchanged if there are no trailing slashes', () => { + // Act + const result = PathFormatter.removeTrailingSlashes('path'); + + // Assert + expect(result).toBe('path'); + }); + }); + + describe('ensureSingleTrailingSlash', () => { + it('should add a single trailing slash when there are none', () => { + // Act + const result = PathFormatter.ensureSingleTrailingSlash('path'); + + // Assert + expect(result).toBe('path/'); + }); + + it('should reduce multiple trailing slashes to a single one', () => { + // Act + const result = PathFormatter.ensureSingleTrailingSlash('path///'); + + // Assert + expect(result).toBe('path/'); + }); + }); + + describe('removeLeadingSlashes', () => { + it('should remove all leading slashes from the path', () => { + // Act + const result = PathFormatter.removeLeadingSlashes('///path'); + + // Assert + expect(result).toBe('path'); + }); + + it('should return the path unchanged if there are no leading slashes', () => { + // Act + const result = PathFormatter.removeLeadingSlashes('path'); + + // Assert + expect(result).toBe('path'); + }); + }); + + describe('ensureSingleLeadingSlash', () => { + it('should add a single leading slash when there are none', () => { + // Act + const result = PathFormatter.ensureSingleLeadingSlash('path'); + + // Assert + expect(result).toBe('/path'); + }); + + it('should reduce multiple leading slashes to a single one', () => { + // Act + const result = PathFormatter.ensureSingleLeadingSlash('///path'); + + // Assert + expect(result).toBe('/path'); + }); + }); +}); diff --git a/tests/unit/http/client.test.ts b/tests/unit/http/client.test.ts index e185f98..532d855 100644 --- a/tests/unit/http/client.test.ts +++ b/tests/unit/http/client.test.ts @@ -1,141 +1,169 @@ -import { - HTTPAuthorizationError, - HTTPBadRequestError, - HTTPError, - HTTPForbiddenError, - HTTPInternalServerError, - HTTPNotFoundError, - HTTPTimeoutError, -} from '../../../src'; -import { HttpClient, StatusCode } from '../../../src/http'; -import { MockAuthManager, MockAuthProvider, MockURLValidator } from '../mocks'; +import { HttpClient } from '../../../src/http'; +import { HTTP_ERROR_ASSOCIATIONS } from '../../fixtures/http-error-associations'; +import { MockHttpClient, MockURLValidator } from '../mocks'; + +import type { RequestInterceptor, ResponseInterceptor } from '../../../src/http'; describe('HttpClient', () => { - let mockAuthManager: MockAuthManager; let mockURLValidator: MockURLValidator; let fetchSpy: jest.SpyInstance; beforeEach(() => { - mockAuthManager = new MockAuthManager(); mockURLValidator = new MockURLValidator(); - fetchSpy = jest.spyOn(globalThis, 'fetch').mockImplementation(() => - Promise.resolve( - new Response(JSON.stringify({ ok: true }), { - status: 200, - }) - ) - ); + fetchSpy = jest + // Mock the global fetch implementation since this is testing the HTTP client capabilities + .spyOn(globalThis, 'fetch') + .mockImplementation(() => { + return Promise.resolve(new Response(JSON.stringify({ ok: true }), { status: 200 })); + }); }); afterEach(() => { jest.restoreAllMocks(); }); - it('should validate baseURL in constructor', () => { - // Arrange & Act - const spy = jest.spyOn(mockURLValidator, 'validate'); - const httpClient = new HttpClient('https://example.com', mockAuthManager, mockURLValidator); + describe('Initialization', () => { + it('should validate baseURL in constructor', () => { + // Arrange & Act + const spy = jest.spyOn(mockURLValidator, 'validate'); + const httpClient = new HttpClient({ baseURL: 'https://example.com' }, mockURLValidator); - // Assert - expect(httpClient).toBeInstanceOf(HttpClient); - expect(spy).toHaveBeenCalledWith('https://example.com'); - }); + // Assert + expect(httpClient).toBeInstanceOf(HttpClient); + expect(spy).toHaveBeenCalledWith('https://example.com'); + }); - it('setBaseURL should validate and update baseURL', () => { - // Arrange - const baseURL = 'https://example.com'; - const newBaseURL = 'https://newurl.com'; + it('should format the baseURL in constructor', () => { + // Arrange & Act + const httpClient = new HttpClient({ baseURL: 'https://example.com/' }, mockURLValidator); - const spy = jest.spyOn(mockURLValidator, 'validate'); + // Assert + expect(httpClient).toBeInstanceOf(HttpClient); + expect(httpClient).toHaveProperty('baseURL', 'https://example.com'); + }); + }); - const httpClient = new HttpClient(baseURL, mockAuthManager, mockURLValidator); + describe('setBaseURL', () => { + it('should validate and update baseURL', () => { + // Arrange + const baseURL = 'https://example.com'; + const newBaseURL = 'https://newurl.com'; - // Act - httpClient.setBaseURL(newBaseURL); + const spy = jest.spyOn(mockURLValidator, 'validate'); - // Assert - expect(spy).toHaveBeenCalledWith(newBaseURL); - expect(httpClient.baseURL).toBe(newBaseURL); - }); + const httpClient = new HttpClient({ baseURL }, mockURLValidator); - it('setAuthStrategy should configure the authentication strategy', () => { - // Arrange - const strategy = MockAuthProvider.identifier; - const strategyOptions = {}; + // Act + httpClient.setBaseURL(newBaseURL); - const spy = jest.spyOn(mockAuthManager, 'setStrategy'); + // Assert + expect(spy).toHaveBeenCalledWith(newBaseURL); + expect(httpClient.baseURL).toBe(newBaseURL); + }); - const httpClient = new HttpClient('https://example.com', mockAuthManager, mockURLValidator); + it('should format and update baseURL', () => { + // Arrange + const baseURL = 'https://example.com'; + const newBaseURL = 'https://newurl.com/'; - // Act - httpClient.setAuthStrategy(MockAuthProvider.identifier, {}); + const httpClient = new HttpClient({ baseURL }, mockURLValidator); - // Assert - expect(spy).toHaveBeenCalledWith(strategy, strategyOptions); - expect(mockAuthManager.strategy).toBe(strategy); + // Act + httpClient.setBaseURL(newBaseURL); + + // Assert + expect(httpClient.baseURL).toBe('https://newurl.com'); + }); }); - it('should try to authenticate before making a request if not already authenticated', async () => { - // Arrange - const authenticateSpy = jest.spyOn(mockAuthManager, 'authenticate'); + describe('setTimeout', () => { + it('should set the request timeout', () => { + // Arrange + const timeout = 500; + const httpClient = new HttpClient({ baseURL: 'https://example.com' }, mockURLValidator); - const httpClient = new HttpClient('https://example.com', mockAuthManager, mockURLValidator); - httpClient.setAuthStrategy(MockAuthProvider.identifier, {}); + // Act + httpClient.setTimeout(timeout); - // Act - await httpClient.fetch('/'); + // Assert + expect(httpClient).toHaveProperty('timeout', timeout); + }); - // Assert - expect(authenticateSpy).toHaveBeenCalled(); + it.each(['foo', 4.2, true, {}, [], null, undefined])( + 'should throw on invalid timeout: %s', + (timeout: unknown) => { + // Arrange + const httpClient = new HttpClient({ baseURL: 'https://example.com' }, mockURLValidator); + + // Act & Assert + // @ts-expect-error the given timeout is purposefully invalid + expect(() => httpClient.setTimeout(timeout)).toThrow( + new TypeError('Timeout must be a safe integer') + ); + } + ); }); - it('fetch should add auth headers to the http request if authenticated', async () => { - // Arrange - const authenticateRequestSpy = jest.spyOn(mockAuthManager, 'authenticateRequest'); - - const httpClient = new HttpClient('https://example.com', mockAuthManager, mockURLValidator); - httpClient.setAuthStrategy(MockAuthProvider.identifier, {}); + describe('CRUD Shorthands', () => { + const methods = ['get', 'post', 'put', 'delete'] as const; - // Act - await httpClient.fetch('/'); + it.each(methods)( + 'should forward the %s request to the base request with the correct method and config', + async (method) => { + // Arrange + const httpClient = new HttpClient({ baseURL: 'https://example.com' }, mockURLValidator); - const authorizationHeader = authenticateRequestSpy.mock.lastCall - ?.at(0) - ?.headers.get('Authorization'); + const requestSpy = jest.spyOn(HttpClient.prototype, 'request'); - // Assert - expect(authenticateRequestSpy).toHaveBeenCalled(); - expect(authorizationHeader).toBe('Bearer '); - }); + // Assert + await httpClient[method]('/'); - it('fetch should add an application/json Content-Type header to each request', async () => { - // Arrange - const httpClient = new HttpClient('https://example.com', mockAuthManager, mockURLValidator); + expect(requestSpy).toHaveBeenCalledWith( + '/', + expect.objectContaining({ method: method.toUpperCase() }) + ); + } + ); - // Act - await httpClient.fetch('/'); + it.each(['get', 'delete'] as const)( + 'should forward the given configuration for %s', + async (method) => { + // Arrange + const headers = { 'Content-Type': 'application/json' }; + const httpClient = new HttpClient({ baseURL: 'https://example.com' }, mockURLValidator); - const contentTypeHeader = fetchSpy.mock.lastCall?.at(0)?.headers.get('Content-Type'); + const requestSpy = jest.spyOn(HttpClient.prototype, 'request'); - // Assert - expect(contentTypeHeader).toBe('application/json'); - }); + // Assert + await httpClient[method]('/', { headers }); - it('fetch should not add auth headers to the http request if not authenticated', async () => { - // Arrange - const authenticateRequestSpy = jest.spyOn(mockAuthManager, 'authenticateRequest'); + expect(requestSpy).toHaveBeenCalledWith( + '/', + expect.objectContaining({ method: method.toUpperCase(), headers }) + ); + } + ); - const httpClient = new HttpClient('https://example.com', mockAuthManager, mockURLValidator); + it.each(['post', 'put'] as const)( + 'should forward the given configuration for %s', + async (method) => { + // Arrange + const headers = { 'Content-Type': 'application/json' }; + const body = JSON.stringify({ payload: 'foobar' }); + const httpClient = new HttpClient({ baseURL: 'https://example.com' }, mockURLValidator); - // Act - await httpClient.fetch('/'); + const requestSpy = jest.spyOn(HttpClient.prototype, 'request'); - const authorizationHeader = fetchSpy.mock.lastCall?.at(0)?.headers.get('Authorization'); + // Assert + await httpClient[method]('/', body, { headers }); - // Assert - expect(authenticateRequestSpy).toHaveBeenCalled(); - expect(authorizationHeader).toBeNull(); + expect(requestSpy).toHaveBeenCalledWith( + '/', + expect.objectContaining({ method: method.toUpperCase(), body, headers }) + ); + } + ); }); it('fetch should forward valid responses', async () => { @@ -146,51 +174,328 @@ describe('HttpClient', () => { return Promise.resolve(new Response(JSON.stringify(payload), { status: 200 })); }); - const httpClient = new HttpClient('https://example.com', mockAuthManager, mockURLValidator); - httpClient.setAuthStrategy(MockAuthProvider.identifier, {}); + const httpClient = new HttpClient({ baseURL: 'https://example.com' }, mockURLValidator); // Act - const response = await httpClient.fetch('/'); + const response = await httpClient.request('/'); // Assert - expect(mockAuthManager.isAuthenticated).toBe(true); await expect(response.json()).resolves.toEqual(payload); }); - it('fetch should handle 401 unauthorized responses', async () => { - // Arrange - const handleUnauthorizedErrorSpy = jest.spyOn(mockAuthManager, 'handleUnauthorizedError'); + describe('Map Response To HTTP Error', () => { + const payload = JSON.stringify({ ok: false }); + + it.each(HTTP_ERROR_ASSOCIATIONS)( + 'should map the response status to the correct exception', + (context, errorClass) => { + // Arrange + const request = new Request('https://example.com/resource', { method: 'GET' }); + const response = new Response(payload, context); + + // Act + const error = HttpClient.mapResponseToHTTPError(response, request); + + // Assert + expect(error).toBeInstanceOf(errorClass); + expect(error.message).toBe( + `Request failed with status code ${context.status} ${context.statusText}: GET https://example.com/resource` + ); + expect(error.response).toBe(response); + expect(error.request).toBe(request); + } + ); + }); - fetchSpy.mockImplementationOnce(() => { - return Promise.resolve(new Response('Unauthorized', { status: 401 })); + describe('Create', () => { + const baseURL = 'https://example.com'; + const timeout = 5000; + + it('should create a copy of the existing http client and preserve its config', () => { + // Arrange + const httpClient = new HttpClient({ baseURL, timeout }, mockURLValidator); + + // Act + const fork = httpClient.create(); + + // Assert + expect(fork).toBeInstanceOf(HttpClient); + expect(fork).not.toBe(httpClient); + expect(fork).toHaveProperty('baseURL', baseURL); + expect(fork).toHaveProperty('timeout', httpClient.timeout); }); - const httpClient = new HttpClient('https://example.com', mockAuthManager, mockURLValidator); - httpClient.setAuthStrategy(MockAuthProvider.identifier, {}); + it('should not mutate the original object when updating properties in the fork', () => { + // Arrange + const httpClient = new HttpClient({ baseURL, timeout }, mockURLValidator); + + const newURL = 'https://newurl.com'; + const newTimeout = 1000; - // Act & Assert - await expect(httpClient.fetch('/')).rejects.toThrow(HTTPAuthorizationError); + // Act + const fork = httpClient.create(); - expect(handleUnauthorizedErrorSpy).toHaveBeenCalled(); - expect(mockAuthManager.isAuthenticated).toBe(false); - }); + fork.setTimeout(1000); + fork.setBaseURL(newURL); + + // Assert + expect(fork).toBeInstanceOf(HttpClient); + expect(fork).not.toBe(httpClient); + + expect(fork).toHaveProperty('baseURL', newURL); + expect(fork).toHaveProperty('timeout', newTimeout); + + expect(httpClient).toHaveProperty('baseURL', baseURL); + expect(httpClient).toHaveProperty('timeout', timeout); + }); - describe('Error Mapping', () => { it.each([ - ['Bad Request', StatusCode.BAD_REQUEST, HTTPBadRequestError], - ['Unauthorized', StatusCode.UNAUTHORIZED, HTTPAuthorizationError], - ['Forbidden', StatusCode.FORBIDDEN, HTTPForbiddenError], - ['Not Found', StatusCode.NOT_FOUND, HTTPNotFoundError], - ['Timeout', StatusCode.TIMEOUT, HTTPTimeoutError], - ['Internal Server', StatusCode.INTERNAL_SERVER_ERROR, HTTPInternalServerError], - ['Unknown', 504, HTTPError], - ])('should throw on %s error', async (_name, status, error) => { + ['baseURL', 'https://newurl.com'], + ['timeout', 1000], + ['headers', { 'Content-Type': 'application/json' }], + ] as const)( + 'should override the %s property when specified in the params', + (property, value) => { + // Arrange + const httpClient = new HttpClient({ baseURL, timeout }, mockURLValidator); + + // Act + const fork = httpClient.create({ [property]: value }); + + // Assert + expect(fork).toBeInstanceOf(HttpClient); + expect(fork).not.toBe(httpClient); + + expect(fork).toHaveProperty(property, value); + } + ); + + it('should inherit the interceptors if specified', () => { // Arrange - fetchSpy.mockImplementationOnce(() => Promise.resolve(new Response('', { status }))); - const httpClient = new HttpClient('https://example.com', mockAuthManager, mockURLValidator); + const request = new Request('https://example.com/resource', { method: 'GET' }); + const response = new Response(JSON.stringify({ ok: true }), { status: 200 }); + + const requestInterceptor: RequestInterceptor = jest.fn(); + const responseInterceptor: ResponseInterceptor = jest.fn(); + + const httpClient = new HttpClient({ baseURL, timeout }, mockURLValidator); + + httpClient.interceptors.request.use(requestInterceptor); + httpClient.interceptors.response.use(responseInterceptor); + + // Act + const fork = httpClient.create(undefined, true); + + fork.interceptors.request.execute({ request }); + fork.interceptors.response.execute({ request, response }); + + // Assert + expect(fork).toBeInstanceOf(HttpClient); + expect(fork).not.toBe(httpClient); + + expect(requestInterceptor).toHaveBeenCalledWith({ request }); + expect(responseInterceptor).toHaveBeenCalledWith({ request, response }); + }); + + it('should not inherit the interceptors if specified', () => { + // Arrange + const request = new Request('https://someothersite.com/resource', { method: 'GET' }); + const response = new Response(JSON.stringify({ ok: false }), { status: 400 }); + + const requestInterceptor: RequestInterceptor = jest.fn(); + const responseInterceptor: ResponseInterceptor = jest.fn(); + + const httpClient = new HttpClient({ baseURL, timeout }, mockURLValidator); + + httpClient.interceptors.request.use(requestInterceptor); + httpClient.interceptors.response.use(responseInterceptor); + + // Act + const fork = httpClient.create(undefined, false); + + fork.interceptors.request.execute({ request }); + fork.interceptors.response.execute({ request, response }); + + // Assert + expect(fork).toBeInstanceOf(HttpClient); + expect(fork).not.toBe(httpClient); + + expect(requestInterceptor).not.toHaveBeenCalledWith({ request }); + expect(responseInterceptor).not.toHaveBeenCalledWith({ request, response }); + }); + + it('should use the same constructor as the current instance', () => { + // Arrange + const instance = new MockHttpClient({ baseURL, timeout }, mockURLValidator); + + // Act + const fork = instance.create(); + + // Assert + expect(fork).toBeInstanceOf(MockHttpClient); + expect(fork).toBeInstanceOf(HttpClient); + + expect(fork.constructor).toBe(instance.constructor); + }); + + it('should throw on invalid base instance', () => { + // Arrange + + // Simulates a failure case where the base prototype is manipulated or replaced + jest.spyOn(Object, 'getPrototypeOf').mockReturnValueOnce(Date.prototype); + + const instance = new HttpClient({ baseURL, timeout }, mockURLValidator); // Act & Assert - await expect(httpClient.fetch('/foo')).rejects.toThrow(error); + expect(() => instance.create()).toThrow( + new Error('The created instance is not an instance of HttpClient') + ); + }); + + it('should correctly retrieve prototype when creating a new instance', () => { + // Arrange + const httpClient = new HttpClient({ baseURL, timeout }, mockURLValidator); + + // Spy on Object.getPrototypeOf + const getPrototypeOfSpy = jest.spyOn(Object, 'getPrototypeOf'); + + // Act + httpClient.create(); + + // Assert + expect(getPrototypeOfSpy).toHaveBeenCalledWith(httpClient); + }); + }); + + describe('Request', () => { + const baseURL = 'https://example.com'; + const timeout = 5000; + const headers = { 'Content-Type': 'application/json' }; + + let client: HttpClient; + let fetchSpy: jest.SpyInstance< + Promise, + [input: RequestInfo | URL, init?: RequestInit | undefined] + >; + + beforeEach(() => { + fetchSpy = jest.spyOn(globalThis, 'fetch'); + client = new HttpClient({ baseURL, timeout, headers }); + }); + + it('should make a GET request to the correct URL', async () => { + // Arrange + const mockResponse = new Response('{"message":"success"}', { status: 200 }); + const expectedURL = `${baseURL}/test`; + + fetchSpy.mockResolvedValue(mockResponse); + + // Act + const response = await client.request('/test', { method: 'GET' }); + + // Assert + expect(fetchSpy).toHaveBeenCalled(); + expect(response).toBe(mockResponse); + + const [url, requestInit] = fetchSpy.mock.lastCall ?? []; + + expect(url).toBeInstanceOf(URL); + expect(url?.toString()).toBe(expectedURL); + + expect(requestInit).toBeInstanceOf(Request); + expect(requestInit).toHaveProperty('method', 'GET'); + }); + + it('should process request and response interceptors', async () => { + // Arrange + fetchSpy.mockResolvedValue(new Response('{"message":"response"}', { status: 200 })); + + const requestInterceptor: RequestInterceptor = jest.fn(({ request }) => { + request.headers.set('X-Request-Interceptor', 'Intercepted'); + + return { request }; + }); + + const responseInterceptor: ResponseInterceptor = jest.fn(({ response, request }) => { + const newResponse = new Response('{"message":"modified response"}', { + status: response.status, + headers: response.headers, + }); + + return { response: newResponse, request }; + }); + + client.interceptors.request.use(requestInterceptor); + client.interceptors.response.use(responseInterceptor); + + // Act + const response = await client.request('/test', {}); + const responseBody = await response.json(); + + // Assert + expect(response.status).toBe(200); + expect(responseBody).toEqual({ message: 'modified response' }); + + expect(requestInterceptor).toHaveBeenCalled(); + expect(responseInterceptor).toHaveBeenCalled(); + }); + + it.each(['/path', 'path', '///path'])( + 'should handle different paths format: %s', + async (path) => { + // Arrange + fetchSpy.mockResolvedValue(new Response('{"message":"success"}', { status: 200 })); + const expectedURL = `${baseURL}/path`; + + // Act + await client.request(path); + + // Assert + expect(fetchSpy).toHaveBeenCalled(); + + const [url, request] = fetchSpy.mock.lastCall ?? []; + + expect(url).toBeInstanceOf(URL); + expect(url?.toString()).toBe(expectedURL); + + expect(request).toBeInstanceOf(Request); + } + ); + + test('should abort the request if it exceeds the specified timeout', async () => { + // Arrange + const baseURL = 'https://example.com'; + const timeout = 0; + const httpClient = new HttpClient({ baseURL, timeout }, mockURLValidator); + + jest.spyOn(globalThis, 'setTimeout'); + jest.spyOn(AbortController.prototype, 'abort'); + + jest.spyOn(globalThis, 'fetch').mockImplementationOnce((_, request) => { + if (!request?.signal) { + // Make the test fail by resolving with a response in case no signal is defined in the request + return Promise.resolve(new Response()); + } + + const { signal } = request; + + return new Promise((resolve, reject) => { + // Make sure the test is not waiting to time out to fail + // If it waited for more than 0 ms (the timeout set), it failed already + const failSafeID = setTimeout(() => resolve(new Response()), 50); + + signal.addEventListener('abort', () => { + clearTimeout(failSafeID); + reject(signal.reason); + }); + }); + }); + + // Act & Assert + await expect(() => httpClient.request('/slow-resource')).rejects.toThrow(); + + expect(AbortController.prototype.abort).toHaveBeenCalled(); + expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), timeout); }); }); }); diff --git a/tests/unit/http/interceptor-manager.test.ts b/tests/unit/http/interceptor-manager.test.ts new file mode 100644 index 0000000..d2a0425 --- /dev/null +++ b/tests/unit/http/interceptor-manager.test.ts @@ -0,0 +1,157 @@ +import { HttpInterceptorManager } from '../../../src/http/interceptor-manager'; + +describe('HttpInterceptorManager', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('use()', () => { + it('should add a fulfilled interceptor to the handler chain', async () => { + // Arrange + const manager = new HttpInterceptorManager(); + + const mockFulfilled = jest.fn((value) => value + 1); + + // Act + manager.use(mockFulfilled); + + const result = await manager.execute(1); + + // Assert + expect(mockFulfilled).toHaveBeenCalledWith(1); + expect(result).toBe(2); + }); + + it('should add both fulfilled and rejected interceptors to the handler chain', async () => { + // Arrange + const manager = new HttpInterceptorManager(); + + const mockFulfilled = jest.fn((value) => value + 1); + const mockRejected = jest.fn((error) => error); + + // Act + manager.use(mockFulfilled, mockRejected); + + const result = await manager.execute(1); + + // Assert + expect(mockFulfilled).toHaveBeenCalledWith(1); + expect(result).toBe(2); + }); + }); + + describe('execute()', () => { + it('should process value through all fulfilled interceptors sequentially', async () => { + // Arrange + const manager = new HttpInterceptorManager(); + + manager.use((value) => value + 1); + manager.use((value) => value * 2); + + // Act + const result = await manager.execute(1); + + // Assert + expect(result).toBe(4); + }); + + it('should handle errors with the corresponding rejected interceptor', async () => { + // Arrange + const manager = new HttpInterceptorManager(); + + const mockRejected = jest.fn(() => 0); + + // Act + manager.use((value) => { + if (value === 1) { + throw new Error('Test error'); + } + + return value; + }, mockRejected); + + const result = await manager.execute(1); + + // Assert + expect(mockRejected).toHaveBeenCalled(); + expect(result).toBe(0); + }); + + it('should propagate an unhandled error', async () => { + // Arrange + const manager = new HttpInterceptorManager(); + + // Act + manager.use((value) => { + if (value === 1) { + throw new Error('Test error'); + } + + return value; + }); + + // Assert + await expect(manager.execute(1)).rejects.toThrow('Test error'); + }); + }); + + describe('clone()', () => { + it('should create a deep clone with the same interceptors', async () => { + // Arrange + const manager = new HttpInterceptorManager(); + + manager.use((value) => value + 1); + + // Act + const clone = manager.clone(); + + const originalResult = await manager.execute(1); + const cloneResult = await clone.execute(1); + + // Assert + expect(originalResult).toBe(2); + expect(cloneResult).toBe(2); + }); + + it('should not affect the original handler chain when a clone is modified', async () => { + // Arrange + const manager = new HttpInterceptorManager(); + + manager.use((value) => value + 1); + + const clone = manager.clone(); + + // Act + clone.use((value) => value * 2); + + const originalResult = await manager.execute(1); + const cloneResult = await clone.execute(1); + + // Assert + expect(originalResult).toBe(2); + expect(cloneResult).toBe(4); + }); + }); + + describe('reject()', () => { + it('should process error through all rejected interceptors sequentially', async () => { + // Arrange + const manager = new HttpInterceptorManager(); + + const mockRejected1 = jest.fn((error) => `${error}-intercepted1`); + const mockRejected2 = jest.fn((error) => `${error}-intercepted2`); + + // Act + manager.use(undefined, mockRejected1); + manager.use(undefined, mockRejected2); + + const result = await manager.reject('Test error'); + + // Assert + expect(mockRejected1).toHaveBeenCalledWith('Test error'); + expect(mockRejected2).toHaveBeenCalledWith('Test error-intercepted1'); + + expect(result).toBe('Test error-intercepted1-intercepted2'); + }); + }); +}); diff --git a/tests/unit/interceptors/auth.test.ts b/tests/unit/interceptors/auth.test.ts new file mode 100644 index 0000000..410e8ba --- /dev/null +++ b/tests/unit/interceptors/auth.test.ts @@ -0,0 +1,142 @@ +import { HTTPAuthorizationError } from '../../../src'; +import { AuthInterceptors } from '../../../src/interceptors'; +import { MockAuthManager, MockAuthProvider, MockHttpClient, MockURLValidator } from '../mocks'; + +describe('AuthInterceptors', () => { + let mockAuthManager: MockAuthManager; + let mockHttpClient: MockHttpClient; + let mockURLValidator: MockURLValidator; + + beforeEach(() => { + mockURLValidator = new MockURLValidator(); + mockAuthManager = new MockAuthManager(); + mockHttpClient = new MockHttpClient({ baseURL: 'https://example.com' }, mockURLValidator); + + jest.spyOn(mockAuthManager, 'authenticate'); + jest.spyOn(mockAuthManager, 'handleUnauthorizedError'); + jest.spyOn(mockAuthManager, 'authenticateRequest'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('ensurePreAuthentication', () => { + it('should trigger authentication if strategy is defined and user is not authenticated', async () => { + // Arrange + mockAuthManager.setStrategy(MockAuthProvider.identifier, {}); + + const interceptor = AuthInterceptors.ensurePreAuthentication(mockAuthManager, mockHttpClient); + const request = new Request('https://example.com'); + + // Act + await interceptor({ request }); + + // Assert + expect(mockAuthManager.authenticate).toHaveBeenCalledWith(mockHttpClient); + expect(mockAuthManager.isAuthenticated).toBe(true); + }); + + it('should not trigger authentication if user is already authenticated', async () => { + // Arrange + mockAuthManager['_isAuthenticated'] = true; + mockAuthManager.setStrategy(MockAuthProvider.identifier, {}); + + const interceptor = AuthInterceptors.ensurePreAuthentication(mockAuthManager, mockHttpClient); + const request = new Request('https://example.com'); + + // Act + await interceptor({ request }); + + // Assert + expect(mockAuthManager.authenticate).not.toHaveBeenCalled(); + }); + + it('should not trigger authentication if no strategy is defined', async () => { + // Arrange + const interceptor = AuthInterceptors.ensurePreAuthentication(mockAuthManager, mockHttpClient); + const request = new Request('https://example.com'); + + // Act + await interceptor({ request }); + + // Assert + expect(mockAuthManager.authenticate).not.toHaveBeenCalled(); + }); + }); + + describe('authenticateRequests', () => { + it('should call authenticateRequest with request', () => { + // Arrange + mockAuthManager.setStrategy(MockAuthProvider.identifier, {}); + + const interceptor = AuthInterceptors.authenticateRequests(mockAuthManager); + const request = new Request('https://example.com'); + + jest.spyOn(request.headers, 'set'); + + // Act + interceptor({ request }); + + // Assert + expect(mockAuthManager.authenticateRequest).toHaveBeenCalledWith(request); + expect(request.headers.set).toHaveBeenCalledWith('Authorization', 'Bearer '); + }); + }); + + describe('notifyOnUnauthorizedResponse', () => { + it('should call handleUnauthorizedError on 401 response status', () => { + // Arrange + const [fulfillment] = AuthInterceptors.notifyOnUnauthorizedResponse(mockAuthManager); + const request = new Request('https://example.com'); + const response = new Response(null, { status: 401 }); + + // Act + fulfillment({ request, response }); + + // Assert + expect(mockAuthManager.handleUnauthorizedError).toHaveBeenCalled(); + }); + + it('should not call handleUnauthorizedError for non-401 response status', () => { + // Arrange + const [fulfillment] = AuthInterceptors.notifyOnUnauthorizedResponse(mockAuthManager); + const request = new Request('https://example.com'); + const response = new Response(null, { status: 200 }); + + // Act + fulfillment({ request, response }); + + // Assert + expect(mockAuthManager.handleUnauthorizedError).not.toHaveBeenCalled(); + }); + + it('should call handleUnauthorizedError on HTTPAuthorizationError rejection', () => { + // Arrange + const [, rejection] = AuthInterceptors.notifyOnUnauthorizedResponse(mockAuthManager); + + const request = new Request('https://example.com'); + const response = new Response(null, { status: 401 }); + + const error = new HTTPAuthorizationError(response, request); + + // Act + rejection(error); + + // Assert + expect(mockAuthManager.handleUnauthorizedError).toHaveBeenCalled(); + }); + + it('should not call handleUnauthorizedError for non HTTPAuthorizationError rejection', () => { + // Arrange + const [, rejection] = AuthInterceptors.notifyOnUnauthorizedResponse(mockAuthManager); + const error = new Error('Other Error'); + + // Act + rejection(error); + + // Assert + expect(mockAuthManager.handleUnauthorizedError).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/interceptors/http.test.ts b/tests/unit/interceptors/http.test.ts new file mode 100644 index 0000000..384cbc5 --- /dev/null +++ b/tests/unit/interceptors/http.test.ts @@ -0,0 +1,65 @@ +import { HttpInterceptors } from '../../../src/interceptors'; +import { HTTP_ERROR_ASSOCIATIONS } from '../../fixtures/http-error-associations'; + +describe('HTTP Interceptors', () => { + describe('setDefaultHeaders', () => { + it('should add the headers to the given request', () => { + // Arrange + const request = new Request('https://example.com'); + + const interceptor = HttpInterceptors.setDefaultHeaders(); + + // Act + interceptor({ request }); + + // Assert + expect(request.headers.get('Content-Type')).toBe('application/json'); + }); + + it('should override the headers in the given request', async () => { + // Arrange + const request = new Request('https://example.com', { + headers: { 'Content-Type': 'text/plain' }, + }); + + const interceptor = HttpInterceptors.setDefaultHeaders(); + + // Act + await interceptor({ request }); + + // Assert + expect(request.headers.get('Content-Type')).toBe('application/json'); + }); + }); + + describe('transformErrors', () => { + const interceptor = HttpInterceptors.transformErrors(); + + it('should do nothing if the response is not an error', async () => { + // Arrange + const request = new Request('https://example.com'); + const response = new Response(null, { status: 200 }); + + // Act + const res = await interceptor({ request, response }); + + // Assert + expect(res.request).toBe(request); + expect(res.response).toBe(response); + }); + + it.each(HTTP_ERROR_ASSOCIATIONS)( + 'should throw the correct error for %j', + async (statuses, errorClass) => { + // Arrange + const request = new Request('https://example.com'); + const response = new Response(null, statuses); + + const action = async () => await interceptor({ request, response }); + + // Act & Assert + await expect(action).rejects.toThrow(errorClass); + } + ); + }); +}); diff --git a/tests/unit/mocks/flaky-url-validator.mock.ts b/tests/unit/mocks/flaky-url-validator.mock.ts new file mode 100644 index 0000000..3d9b38c --- /dev/null +++ b/tests/unit/mocks/flaky-url-validator.mock.ts @@ -0,0 +1,12 @@ +import { URLValidator } from '../../../src/validators'; + +/** + * Class representing a FlakyURLValidator which extends URLValidator. + * + * This validator is designed to throw an error unexpectedly upon validation and should only be used in test suites. + */ +export class MockFlakyURLValidator extends URLValidator { + validate() { + throw new Error('Unexpected error'); + } +} diff --git a/tests/unit/mocks/http-client.mock.ts b/tests/unit/mocks/http-client.mock.ts index ac640c5..1f321a5 100644 --- a/tests/unit/mocks/http-client.mock.ts +++ b/tests/unit/mocks/http-client.mock.ts @@ -1,26 +1,16 @@ import { HttpClient } from '../../../src/http'; -import { MockAuthManager } from './auth-manager.mock'; -import { MockAuthProviderFactory } from './auth-provider-factory.mock'; import { MockURLValidator } from './url-validator.mock'; -import type { AuthManager } from '../../../src/auth'; +import type { HttpClientConfig } from '../../../src/http'; import type { URLValidator } from '../../../src/validators'; export class MockHttpClient extends HttpClient { - constructor( - baseURL: string, - authManager: AuthManager = new MockAuthManager(new MockAuthProviderFactory()), - urlValidator: URLValidator = new MockURLValidator() - ) { - super(baseURL, authManager, urlValidator); + constructor(config: HttpClientConfig, urlValidator: URLValidator = new MockURLValidator()) { + super(config, urlValidator); } - fetch() { - return this._fetch(); - } - - _fetch() { + fetch(_input: RequestInfo | URL, _init?: RequestInit): Promise { return Promise.resolve(new Response(JSON.stringify({ ok: true }), { status: 200 })); } } diff --git a/tests/unit/mocks/index.ts b/tests/unit/mocks/index.ts index 6242bf0..1943dcc 100644 --- a/tests/unit/mocks/index.ts +++ b/tests/unit/mocks/index.ts @@ -3,6 +3,7 @@ export { MockAuthProviderFactory } from './auth-provider-factory.mock'; export { MockAuthManager } from './auth-manager.mock'; export { MockHttpClient } from './http-client.mock'; export { MockURLValidator } from './url-validator.mock'; +export { MockFlakyURLValidator } from './flaky-url-validator.mock'; export { MockStrapiSDKValidator } from './sdk-validator.mock'; export { mockRequest } from './request.mock'; export { mockResponse } from './response.mock'; diff --git a/tests/unit/mocks/request.mock.ts b/tests/unit/mocks/request.mock.ts index f13e4aa..3e0c44e 100644 --- a/tests/unit/mocks/request.mock.ts +++ b/tests/unit/mocks/request.mock.ts @@ -17,6 +17,7 @@ export const mockRequest = (method: string, url: string): Request => ({ signal: AbortSignal.any([]), arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(0)), blob: jest.fn().mockResolvedValue(new Blob()), + bytes: jest.fn().mockResolvedValue(new Uint8Array()), formData: jest.fn().mockResolvedValue(new FormData()), text: jest.fn().mockResolvedValue(''), json: jest.fn().mockResolvedValue({}), diff --git a/tests/unit/mocks/response.mock.ts b/tests/unit/mocks/response.mock.ts index 0edf191..c7f442c 100644 --- a/tests/unit/mocks/response.mock.ts +++ b/tests/unit/mocks/response.mock.ts @@ -12,6 +12,7 @@ export const mockResponse = (status: number, statusText: string): Response => ({ text: jest.fn().mockResolvedValue(''), json: jest.fn().mockResolvedValue({}), blob: jest.fn().mockResolvedValue(new Blob()), + bytes: jest.fn().mockResolvedValue(new Uint8Array()), formData: jest.fn().mockResolvedValue(new FormData()), arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(0)), }); diff --git a/tests/unit/sdk.test.ts b/tests/unit/sdk.test.ts index c75b043..33442c7 100644 --- a/tests/unit/sdk.test.ts +++ b/tests/unit/sdk.test.ts @@ -1,29 +1,35 @@ -import { StrapiSDKInitializationError } from '../../src'; +import { + HTTPAuthorizationError, + HTTPBadRequestError, + HTTPError, + HTTPForbiddenError, + HTTPInternalServerError, + HTTPNotFoundError, + HTTPTimeoutError, + StrapiSDKInitializationError, +} from '../../src'; import { CollectionTypeManager, SingleTypeManager } from '../../src/content-types'; +import { HttpClient, StatusCode } from '../../src/http'; import { StrapiSDK } from '../../src/sdk'; -import { StrapiSDKValidator, URLValidator } from '../../src/validators'; +import { StrapiSDKValidator } from '../../src/validators'; -import { MockAuthProvider, MockHttpClient, MockStrapiSDKValidator } from './mocks'; +import { + MockAuthManager, + MockAuthProvider, + MockHttpClient, + MockStrapiSDKValidator, + MockFlakyURLValidator, +} from './mocks'; +import type { HttpClientConfig } from '../../src/http'; import type { StrapiSDKConfig } from '../../src/sdk'; -/** - * Class representing a FlakyURLValidator which extends URLValidator. - * - * This validator is designed to throw an error unexpectedly upon validation and should only be used in test suites. - */ -class FlakyURLValidator extends URLValidator { - validate() { - throw new Error('Unexpected error'); - } -} - describe('StrapiSDK', () => { - const mockHttpClientFactory = (url: string) => new MockHttpClient(url); + const mockHttpClientFactory = (config: HttpClientConfig) => new MockHttpClient(config); beforeEach(() => { jest - .spyOn(MockHttpClient.prototype, '_fetch') + .spyOn(MockHttpClient.prototype, 'fetch') .mockImplementation(() => Promise.resolve( new Response(JSON.stringify({ data: { id: 1 }, meta: {} }), { status: 200 }) @@ -31,30 +37,49 @@ describe('StrapiSDK', () => { ); }); + afterEach(() => { + jest.restoreAllMocks(); + }); + describe('Initialization', () => { it('should initialize with valid config', () => { // Arrange const config = { - baseURL: 'http://localhost:1337/api', + baseURL: 'https://localhost:1337/api', auth: { strategy: MockAuthProvider.identifier, options: {} }, } satisfies StrapiSDKConfig; const mockValidator = new MockStrapiSDKValidator(); + const mockAuthManager = new MockAuthManager(); const sdkValidatorSpy = jest.spyOn(mockValidator, 'validateConfig'); - const httpSetAuthStrategySpy = jest.spyOn(MockHttpClient.prototype, 'setAuthStrategy'); + const authSetStrategySpy = jest.spyOn(MockAuthManager.prototype, 'setStrategy'); // Act - const sdk = new StrapiSDK(config, mockValidator, mockHttpClientFactory); + const sdk = new StrapiSDK(config, mockValidator, mockAuthManager, mockHttpClientFactory); // Assert - // instance expect(sdk).toBeInstanceOf(StrapiSDK); - // internal Validation expect(sdkValidatorSpy).toHaveBeenCalledWith(config); - // internal setup - expect(httpSetAuthStrategySpy).toHaveBeenCalledWith(MockAuthProvider.identifier, {}); + expect(authSetStrategySpy).toHaveBeenCalledWith(MockAuthProvider.identifier, {}); + }); + + it('should not set the auth strategy if no auth config is provided', () => { + // Arrange + const config = { baseURL: 'https://localhost:1337/api' } satisfies StrapiSDKConfig; + + const mockValidator = new MockStrapiSDKValidator(); + const mockAuthManager = new MockAuthManager(); + + const authSetStrategySpy = jest.spyOn(MockAuthManager.prototype, 'setStrategy'); + + // Act + const sdk = new StrapiSDK(config, mockValidator, mockAuthManager, mockHttpClientFactory); + + // Assert + expect(sdk).toBeInstanceOf(StrapiSDK); + expect(authSetStrategySpy).not.toHaveBeenCalled(); }); it('should throw an error on invalid baseURL', () => { @@ -74,15 +99,15 @@ describe('StrapiSDK', () => { // Arrange let sdk!: StrapiSDK; - const baseURL = 'http://example.com'; + const baseURL = 'https://example.com'; const config: StrapiSDKConfig = { baseURL } satisfies StrapiSDKConfig; const expectedError = new StrapiSDKInitializationError(new Error('Unexpected error')); - const validateSpy = jest.spyOn(FlakyURLValidator.prototype, 'validate'); + const validateSpy = jest.spyOn(MockFlakyURLValidator.prototype, 'validate'); // Act const createSDK = () => { - sdk = new StrapiSDK(config, new StrapiSDKValidator(new FlakyURLValidator())); + sdk = new StrapiSDK(config, new StrapiSDKValidator(new MockFlakyURLValidator())); }; // Assert @@ -96,7 +121,7 @@ describe('StrapiSDK', () => { it('should initialize correctly with the default validator', () => { // Arrange - const sdk = new StrapiSDK({ baseURL: 'http://localhost:1337/api' }); + const sdk = new StrapiSDK({ baseURL: 'https://localhost:1337/api' }); // Act & Assert expect(sdk).toBeInstanceOf(StrapiSDK); @@ -107,10 +132,12 @@ describe('StrapiSDK', () => { it('should return a new CollectionTypeManager instance when given a resource name', () => { // Arrange const resource = 'articles'; - const config = { baseURL: 'http://localhost:1337/api' } satisfies StrapiSDKConfig; + const config = { baseURL: 'https://localhost:1337/api' } satisfies StrapiSDKConfig; const mockValidator = new MockStrapiSDKValidator(); - const sdk = new StrapiSDK(config, mockValidator, mockHttpClientFactory); + const mockAuthManager = new MockAuthManager(); + + const sdk = new StrapiSDK(config, mockValidator, mockAuthManager, mockHttpClientFactory); // Act const collection = sdk.collection(resource); @@ -125,10 +152,12 @@ describe('StrapiSDK', () => { it('should return a new SingleTypeManager instance when given a resource name', () => { // Arrange const resource = 'homepage'; - const config = { baseURL: 'http://localhost:1337/api' } satisfies StrapiSDKConfig; + const config = { baseURL: 'https://localhost:1337/api' } satisfies StrapiSDKConfig; const mockValidator = new MockStrapiSDKValidator(); - const sdk = new StrapiSDK(config, mockValidator, mockHttpClientFactory); + const mockAuthManager = new MockAuthManager(); + + const sdk = new StrapiSDK(config, mockValidator, mockAuthManager, mockHttpClientFactory); // Act const single = sdk.single(resource); @@ -139,33 +168,194 @@ describe('StrapiSDK', () => { }); }); - // todo implement validation capabilities for providers (e.g. checks if the provided auth strategy exists before trying to create a provider instance) - it.todo('should throw an error on invalid auth configuration'); + describe('Custom Interceptors', () => { + describe('HTTP', () => { + it('fetch should add an application/json Content-Type header to each request', async () => { + // Arrange + const path = '/homepage'; + const config = { baseURL: 'https://localhost:1337/api' } satisfies StrapiSDKConfig; + + const mockValidator = new MockStrapiSDKValidator(); + const mockAuthManager = new MockAuthManager(); + + const sdk = new StrapiSDK(config, mockValidator, mockAuthManager, mockHttpClientFactory); + + const fetchSpy = jest.spyOn(MockHttpClient.prototype, 'fetch'); + + // Act + await sdk.fetch(path); + const headers = fetchSpy.mock.lastCall?.[1]?.headers; + + // Assert + expect(headers).toBeDefined(); + expect(headers).toBeInstanceOf(Headers); + expect((headers as Headers).get('Content-Type')).toBe('application/json'); + }); + + it.each([ + ['Bad Request', StatusCode.BAD_REQUEST, HTTPBadRequestError], + ['Unauthorized', StatusCode.UNAUTHORIZED, HTTPAuthorizationError], + ['Forbidden', StatusCode.FORBIDDEN, HTTPForbiddenError], + ['Not Found', StatusCode.NOT_FOUND, HTTPNotFoundError], + ['Timeout', StatusCode.TIMEOUT, HTTPTimeoutError], + ['Internal Server', StatusCode.INTERNAL_SERVER_ERROR, HTTPInternalServerError], + ['Unknown', 504, HTTPError], + ])('should throw an HTTP exception on %s error', async (_name, status, error) => { + // Arrange + const path = '/homepage'; + const config = { baseURL: 'https://localhost:1337/api' } satisfies StrapiSDKConfig; + + const mockValidator = new MockStrapiSDKValidator(); + const mockAuthManager = new MockAuthManager(); + + const sdk = new StrapiSDK(config, mockValidator, mockAuthManager, mockHttpClientFactory); + + jest + .spyOn(MockHttpClient.prototype, 'fetch') + // Simulate an error in the http client low-level fetch + .mockImplementationOnce(() => Promise.resolve(new Response(null, { status }))); + + // Act & Assert + await expect(sdk.fetch(path)).rejects.toThrow(error); + }); + }); + + describe('Auth', () => { + it('should ensure the user is pre-authenticated before a fetch is executed', async () => { + // Arrange + const config = { + baseURL: 'https://localhost:1337/api', + auth: { strategy: MockAuthProvider.identifier }, + } satisfies StrapiSDKConfig; + + const mockValidator = new MockStrapiSDKValidator(); + const mockAuthManager = new MockAuthManager(); + + const authenticateSpy = jest.spyOn(MockAuthManager.prototype, 'authenticate'); + + const sdk = new StrapiSDK(config, mockValidator, mockAuthManager, mockHttpClientFactory); + + // Act + await sdk.fetch('/'); + + // Assert + expect(authenticateSpy).toHaveBeenCalledWith(expect.any(HttpClient)); + }); + + it('should authenticates outgoing HTTP requests by injecting authentication-specific headers', async () => { + // Arrange + const config = { + baseURL: 'https://localhost:1337/api', + auth: { strategy: MockAuthProvider.identifier }, + } satisfies StrapiSDKConfig; + + const mockValidator = new MockStrapiSDKValidator(); + const mockAuthManager = new MockAuthManager(); + + const authenticateRequestSpy = jest.spyOn(MockAuthManager.prototype, 'authenticateRequest'); + + const sdk = new StrapiSDK(config, mockValidator, mockAuthManager, mockHttpClientFactory); + + // Act + await sdk.fetch('/'); + + // Assert + expect(authenticateRequestSpy).toHaveBeenCalledWith(expect.any(Request)); + + const { headers } = authenticateRequestSpy.mock.lastCall?.at(0) ?? {}; + + expect(headers).toBeDefined(); + expect(headers).toBeInstanceOf(Headers); + expect((headers as Headers).get('Authorization')).toBe('Bearer '); + }); + + it(`shouldn't authenticates outgoing HTTP requests if no auth strategy is set`, async () => { + // Arrange + const config = { baseURL: 'https://localhost:1337/api' } satisfies StrapiSDKConfig; + + const mockValidator = new MockStrapiSDKValidator(); + const mockAuthManager = new MockAuthManager(); + + const authenticateRequestSpy = jest.spyOn(MockAuthManager.prototype, 'authenticateRequest'); + + const sdk = new StrapiSDK(config, mockValidator, mockAuthManager, mockHttpClientFactory); + + // Act + await sdk.fetch('/'); + + // Assert + expect(authenticateRequestSpy).toHaveBeenCalledWith(expect.any(Request)); + + const { headers } = authenticateRequestSpy.mock.lastCall?.at(0) ?? {}; + + expect(headers).toBeDefined(); + expect(headers).toBeInstanceOf(Headers); + expect((headers as Headers).get('Authorization')).toBeNull(); + }); + + it('fetch should handle 401 unauthorized responses', async () => { + // Arrange + const config = { + baseURL: 'https://localhost:1337/api', + auth: { strategy: MockAuthProvider.identifier }, + } satisfies StrapiSDKConfig; + + const mockValidator = new MockStrapiSDKValidator(); + const mockAuthManager = new MockAuthManager(); + + const sdk = new StrapiSDK(config, mockValidator, mockAuthManager, mockHttpClientFactory); + + const spies = { + authenticate: jest.spyOn(MockAuthManager.prototype, 'authenticate'), + authenticateRequest: jest.spyOn(MockAuthManager.prototype, 'authenticateRequest'), + handleUnauthorizedError: jest.spyOn(MockAuthManager.prototype, 'handleUnauthorizedError'), + }; + + jest + .spyOn(MockHttpClient.prototype, 'fetch') + // Simulate an 'Unauthorized' error in the http client low-level fetch + .mockImplementation(() => Promise.resolve(new Response('Unauthorized', { status: 401 }))); + + // Act & Assert + await expect(sdk.fetch('/')).rejects.toThrow(HTTPAuthorizationError); + + expect(spies.authenticate).toHaveBeenCalledWith(expect.any(HttpClient)); + expect(spies.authenticateRequest).toHaveBeenCalledWith(expect.any(Request)); + + expect(spies.handleUnauthorizedError).toHaveBeenCalled(); + + // isAuthenticated should have been set to false by AuthManager.handleUnauthorizedError + expect(mockAuthManager.isAuthenticated).toBe(false); + }); + }); + }); it('should fetch data correctly with fetch method', async () => { // Arrange - const config = { baseURL: 'http://localhost:1337/api' } satisfies StrapiSDKConfig; + const config = { baseURL: 'https://localhost:1337/api' } satisfies StrapiSDKConfig; - const fetchSpy = jest.spyOn(MockHttpClient.prototype, 'fetch'); + const requestSpy = jest.spyOn(MockHttpClient.prototype, 'request'); const mockValidator = new MockStrapiSDKValidator(); - const sdk = new StrapiSDK(config, mockValidator, mockHttpClientFactory); + const mockAuthManager = new MockAuthManager(); + const sdk = new StrapiSDK(config, mockValidator, mockAuthManager, mockHttpClientFactory); // Act const response = await sdk.fetch('/data'); // Assert - expect(fetchSpy).toHaveBeenCalledWith('/data', undefined); + expect(requestSpy).toHaveBeenCalledWith('/data', undefined); await expect(response.json()).resolves.toEqual({ data: { id: 1 }, meta: {} }); }); it('should retrieve baseURL correctly from config', () => { // Arrange - const config = { baseURL: 'http://localhost:1337/api' } satisfies StrapiSDKConfig; + const config = { baseURL: 'https://localhost:1337/api' } satisfies StrapiSDKConfig; const mockValidator = new MockStrapiSDKValidator(); + const mockAuthManager = new MockAuthManager(); - const sdk = new StrapiSDK(config, mockValidator, mockHttpClientFactory); + const sdk = new StrapiSDK(config, mockValidator, mockAuthManager, mockHttpClientFactory); // Act const { baseURL } = sdk;