From 08cf6e1ad4914462792b023c4fd49e29ee1e5c1c Mon Sep 17 00:00:00 2001 From: Umut Ay Bora Date: Mon, 6 Apr 2026 14:01:11 +0200 Subject: [PATCH] feat: add buildRaw() to WasapiClient.Builder Returns a raw WasapiClient instance for direct execute() calls without needing a decorated API class. Used by singularity's Steps API for generic HTTP methods. Powered by Civitas Cerebrum --- .github/workflows/coverage.yml | 69 ++ .github/workflows/publish.yml | 59 + .github/workflows/test.yml | 57 + .gitignore | 14 +- .idea/.gitignore | 5 + .idea/compiler.xml | 13 + .idea/encodings.xml | 7 + .idea/inspectionProfiles/Project_Default.xml | 8 + .idea/jarRepositories.xml | 20 + .idea/misc.xml | 12 + .idea/vcs.xml | 6 + package-lock.json | 1128 ++++++++++++++++++ package.json | 38 + src/client/WasapiClient.ts | 288 +++++ src/collections/ResponsePair.ts | 13 + src/decorators/http.ts | 79 ++ src/exceptions/FailedCallException.ts | 15 + src/exceptions/WasapiException.ts | 6 + src/index.ts | 20 + src/logger/Logger.ts | 36 + src/models/ApiCall.ts | 193 +++ src/models/ApiResponse.ts | 68 ++ src/models/types.ts | 37 + test-coverage-report.txt | 26 + tests/book-hive.test.ts | 474 ++++++++ tsconfig.json | 16 + 26 files changed, 2698 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/coverage.yml create mode 100644 .github/workflows/publish.yml create mode 100644 .github/workflows/test.yml create mode 100644 .idea/.gitignore create mode 100644 .idea/compiler.xml create mode 100644 .idea/encodings.xml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/jarRepositories.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/vcs.xml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/client/WasapiClient.ts create mode 100644 src/collections/ResponsePair.ts create mode 100644 src/decorators/http.ts create mode 100644 src/exceptions/FailedCallException.ts create mode 100644 src/exceptions/WasapiException.ts create mode 100644 src/index.ts create mode 100644 src/logger/Logger.ts create mode 100644 src/models/ApiCall.ts create mode 100644 src/models/ApiResponse.ts create mode 100644 src/models/types.ts create mode 100644 test-coverage-report.txt create mode 100644 tests/book-hive.test.ts create mode 100644 tsconfig.json diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..50a7924 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,69 @@ +name: ๐Ÿ“Š API Coverage Report + +on: + pull_request: + branches: [ main ] + +permissions: + pull-requests: write + +jobs: + coverage: + runs-on: ubuntu-latest + + steps: + - name: ๐Ÿ—‚๏ธ Checkout repository + uses: actions/checkout@v4 + + - name: โš™๏ธ Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'npm' + + - name: ๐Ÿงฉ Install dependencies + run: npm ci + + - name: ๐Ÿ› ๏ธ Build package + run: npm run build + + - name: ๐Ÿ“Š Generate & Post Table Report + if: always() + uses: actions/github-script@v7 + env: + REPORT_FORMAT: 'plain' + with: + script: | + const fs = require('fs'); + const cp = require('child_process'); + + const config = { + table: { flag: 'github-table', header: 'Table report'}, + plain: { flag: 'github-plain', header: 'Plain report' } + }[process.env.REPORT_FORMAT]; + + cp.execSync(`npx test-coverage --format=${config.flag}`); + const body = fs.readFileSync('test-coverage-report.md', 'utf-8'); + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const existing = comments.find(c => + c.user.login === 'github-actions[bot]' && + c.body.includes(config.header) + ); + + const commentPayload = { + owner: context.repo.owner, + repo: context.repo.repo, + body: body + }; + + if (existing) { + await github.rest.issues.updateComment({ ...commentPayload, comment_id: existing.id }); + } else { + await github.rest.issues.createComment({ ...commentPayload, issue_number: context.issue.number }); + } diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..637f898 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,59 @@ +name: ๐Ÿš€ Publish Package + +on: + push: + tags: + - '*' + +permissions: + id-token: write + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + + steps: + - name: ๐Ÿ—‚๏ธ Checkout repository + uses: actions/checkout@v4 + + - name: โš™๏ธ Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + registry-url: https://registry.npmjs.org + + - name: ๐Ÿงฉ Install dependencies + run: npm ci + + - name: ๐Ÿ› ๏ธ Build package + run: npm run build --if-present + + - name: ๐Ÿณ Start BookHive API + run: | + git clone --depth 1 https://github.com/umutayb/book-hive.git /tmp/book-hive + cd /tmp/book-hive + docker compose up --build -d + cd $GITHUB_WORKSPACE + + - name: โณ Wait for API readiness + run: | + for i in $(seq 1 30); do + if curl -s http://localhost:8080/api/health | grep -q '"healthy"'; then + echo "API is ready" + break + fi + echo "Waiting for API... ($i/30)" + sleep 2 + done + + - name: ๐ŸŒฑ Seed test data + run: curl -s -X POST http://localhost:8080/api/reset + + - name: ๐Ÿงช Run All Tests + run: npx tsx tests/book-hive.test.ts + + - name: ๐Ÿ“ฆ Publish Package + run: npm publish --access public --provenance + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..6f3fcb4 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,57 @@ +name: ๐Ÿงช Test, Verify & Coverage + +on: + pull_request: + branches: [ main ] + push: + branches: [ main ] + +permissions: + pull-requests: write + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: ๐Ÿ—‚๏ธ Checkout repository + uses: actions/checkout@v4 + + - name: โš™๏ธ Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'npm' + + - name: ๐Ÿงฉ Install dependencies + run: npm ci + + - name: ๐Ÿ› ๏ธ Build package + run: npm run build + + - name: ๐Ÿณ Start BookHive API + run: | + git clone --depth 1 https://github.com/umutayb/book-hive.git /tmp/book-hive + cd /tmp/book-hive + docker compose up --build -d + cd $GITHUB_WORKSPACE + + - name: โณ Wait for API readiness + run: | + for i in $(seq 1 30); do + if curl -s http://localhost:8080/api/health | grep -q '"healthy"'; then + echo "API is ready" + break + fi + echo "Waiting for API... ($i/30)" + sleep 2 + done + + - name: ๐ŸŒฑ Seed test data + run: curl -s -X POST http://localhost:8080/api/reset + + - name: ๐Ÿงช Run Integration Tests + run: npx tsx tests/book-hive.test.ts + + - name: ๐Ÿ“ Check Types + run: npx tsc --noEmit diff --git a/.gitignore b/.gitignore index 6125b25..ee34ba1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,6 @@ -.idea/vcs.xml -/out/ -/.idea/ -POM-Framework.iml -*.iml +/dist +/node_modules +*.tgz .DS_Store -/inbox/ -src/test/resources/secret.properties -/target/ -mongo-init.js +.env +.env.local diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..a0ccf77 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,5 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Environment-dependent path to Maven home directory +/mavenHomeManager.xml diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..0477a2c --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..aa00ffa --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..a3c0d25 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml new file mode 100644 index 0000000..712ab9d --- /dev/null +++ b/.idea/jarRepositories.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..322c5da --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f1a611b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1128 @@ +{ + "name": "@civitas-cerebrum/wasapi", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@civitas-cerebrum/wasapi", + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "@civitas-cerebrum/context-store": "^0.0.2", + "@civitas-cerebrum/test-coverage": "^0.0.9", + "debug": "^4.4.3" + }, + "devDependencies": { + "@types/debug": "^4.1.13", + "@types/node": "^20.0.0", + "tsx": "^4.21.0", + "typescript": "^5.0.0" + } + }, + "node_modules/@civitas-cerebrum/context-store": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@civitas-cerebrum/context-store/-/context-store-0.0.2.tgz", + "integrity": "sha512-v5j/L1+ngS1hge5mmNtsIqby9c6+LPivlGn/VqDAzg06wAtNQ1uEy4bSTI4zyNovEh03MeNEzSNcz6Ob+2N40A==", + "license": "MIT" + }, + "node_modules/@civitas-cerebrum/test-coverage": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/@civitas-cerebrum/test-coverage/-/test-coverage-0.0.9.tgz", + "integrity": "sha512-+fnvV+dD2SnxbYAjlN9sI3oyh/VIzgW/T8Y6i7Y+Vttyw9DdJ3wesN+bWkxVGF8F1GAf4LKg1BRGlw5vjy5agQ==", + "license": "MIT", + "dependencies": { + "glob": "^10.4.5", + "typescript": "^5.0.0" + }, + "bin": { + "test-coverage": "dist/cli.js" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..99f2d6e --- /dev/null +++ b/package.json @@ -0,0 +1,38 @@ +{ + "name": "@civitas-cerebrum/wasapi", + "version": "0.0.1", + "description": "A lightweight REST API client library with fluent builder, typed responses, and decorator-based API definitions.", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "clean": "rm -rf dist", + "build": "npm run clean && tsc", + "test": "npx tsx tests/book-hive.test.ts", + "test:coverage": "npx test-coverage", + "prepublishOnly": "npm run build" + }, + "files": [ + "dist" + ], + "dependencies": { + "@civitas-cerebrum/context-store": "^0.0.2", + "@civitas-cerebrum/test-coverage": "^0.0.9", + "debug": "^4.4.3" + }, + "devDependencies": { + "@types/debug": "^4.1.13", + "@types/node": "^20.0.0", + "tsx": "^4.21.0", + "typescript": "^5.0.0" + }, + "publishConfig": { + "access": "public", + "provenance": true + }, + "repository": { + "type": "git", + "url": "git+https://github.com/civitas-cerebrum/wasapi.git" + }, + "author": "Umut Ay Bora", + "license": "MIT" +} diff --git a/src/client/WasapiClient.ts b/src/client/WasapiClient.ts new file mode 100644 index 0000000..6af11a3 --- /dev/null +++ b/src/client/WasapiClient.ts @@ -0,0 +1,288 @@ +import { ContextStore } from '@civitas-cerebrum/context-store'; +import { ClientConfig, HttpMethod, RequestConfig } from '../models/types'; +import { ApiResponse } from '../models/ApiResponse'; +import { ApiCall } from '../models/ApiCall'; +import { WasapiException } from '../exceptions/WasapiException'; +import { httpMetadata } from '../decorators/http'; +import { log, createLogger } from '../logger/Logger'; + +const BODY_METHODS = new Set([HttpMethod.POST, HttpMethod.PUT, HttpMethod.PATCH]); + +export class WasapiClient { + readonly config: ClientConfig; + + private constructor(config: ClientConfig) { + this.config = config; + } + + async execute(requestConfig: RequestConfig): Promise> { + const url = this.buildUrl(requestConfig); + const headers = { ...this.config.headers, ...requestConfig.headers }; + const reqLog = createLogger('request'); + const resLog = createLogger('response'); + + // Build fetch options + const init: RequestInit = { + method: typeof requestConfig.method === 'string' ? requestConfig.method : requestConfig.method, + headers, + redirect: this.config.followRedirects ? 'follow' : 'manual', + }; + + // Body + if (requestConfig.formData) { + init.body = requestConfig.formData; + } else if (requestConfig.body !== undefined) { + init.body = JSON.stringify(requestConfig.body); + if (!headers['Content-Type'] && !headers['content-type']) { + (init.headers as Record)['Content-Type'] = 'application/json'; + } + } + + // Timeout via AbortController + const timeout = requestConfig.timeout ?? this.config.timeout; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeout * 1000); + init.signal = controller.signal; + + // Log request + if (this.config.logHeaders) { + reqLog('%s %s', requestConfig.method, url); + for (const [k, v] of Object.entries(headers)) { + reqLog(' %s: %s', k, v); + } + } else { + reqLog('%s %s', requestConfig.method, url); + } + + if (this.config.logRequestBody && requestConfig.body !== undefined) { + reqLog('Body: %O', requestConfig.body); + } + + try { + const res = await fetch(url, init); + clearTimeout(timer); + + const apiResponse = await ApiResponse.fromFetch(res); + + // Log response + resLog('%d %s', apiResponse.status, apiResponse.statusText); + + if (this.config.detailedLogging && apiResponse.rawBody) { + resLog('Body: %s', apiResponse.rawBody); + } + + return apiResponse; + } catch (err: unknown) { + clearTimeout(timer); + if (err instanceof DOMException && err.name === 'AbortError') { + throw new WasapiException(`Request timed out after ${timeout}s: ${requestConfig.method} ${url}`); + } + throw err; + } + } + + private buildUrl(config: RequestConfig): string { + let path = config.path; + + // Substitute path params โ€” :param style + if (config.pathParams) { + for (const [key, value] of Object.entries(config.pathParams)) { + path = path.replace(`:${key}`, encodeURIComponent(value)); + } + } + + const base = this.config.baseUrl.replace(/\/+$/, ''); + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + let url = `${base}${normalizedPath}`; + + // Query params + if (config.queryParams) { + const params = new URLSearchParams(config.queryParams); + const qs = params.toString(); + if (qs) { + url += `?${qs}`; + } + } + + return url; + } + + /** + * Build a typed API proxy from a decorated class. + * Mirrors Retrofit's `retrofit.create(Service.class)`. + */ + build(ApiClass: new () => T): T { + // Instantiate to trigger decorator registration + const instance = new ApiClass(); + const client = this; + + return new Proxy(instance as object, { + get(target, prop, receiver) { + const methodName = String(prop); + const meta = httpMetadata.get(ApiClass.prototype, methodName); + + if (!meta) { + return Reflect.get(target, prop, receiver); + } + + return (...args: unknown[]) => { + const hasBody = meta.hasBody ?? BODY_METHODS.has(meta.method as HttpMethod); + + let body: unknown | undefined; + let pathParams: Record | undefined; + let queryParams: Record | undefined; + let options: { headers?: Record; timeout?: number } | undefined; + + if (hasBody) { + [body, pathParams, queryParams, options] = args as [ + unknown, + Record?, + Record?, + { headers?: Record; timeout?: number }?, + ]; + } else { + [pathParams, queryParams, options] = args as [ + Record?, + Record?, + { headers?: Record; timeout?: number }?, + ]; + } + + const requestConfig: RequestConfig = { + method: meta.method, + path: meta.path, + body, + pathParams, + queryParams, + headers: options?.headers, + timeout: options?.timeout, + }; + + return new ApiCall(client, requestConfig); + }; + }, + }) as T; + } + + static Builder = class Builder { + #baseUrl = ''; + #headers: Record = {}; + #timeout = 60; + #proxy: { host: string; port: number } | null = null; + #hostnameVerification = true; + #logHeaders = true; + #logRequestBody = false; + #detailedLogging = false; + #followRedirects = false; + + constructor(store?: ContextStore) { + if (store) { + this.#baseUrl = store.get('wasapi.baseUrl', ''); + this.#timeout = store.getNumber('wasapi.timeout', 60); + this.#logHeaders = store.getBoolean('wasapi.logHeaders', true); + this.#logRequestBody = store.getBoolean('wasapi.logRequestBody', false); + this.#detailedLogging = store.getBoolean('wasapi.detailedLogging', false); + this.#hostnameVerification = store.getBoolean('wasapi.hostnameVerification', true); + this.#followRedirects = store.getBoolean('wasapi.followRedirects', false); + + const proxyHost = store.get('wasapi.proxyHost', ''); + if (proxyHost) { + this.#proxy = { + host: proxyHost, + port: store.getNumber('wasapi.proxyPort', 8888), + }; + } + } + } + + setBaseUrl(url: string): this { + this.#baseUrl = url; + return this; + } + + setHeaders(headers: Record): this { + this.#headers = { ...this.#headers, ...headers }; + return this; + } + + setTimeout(seconds: number): this { + this.#timeout = seconds; + return this; + } + + setProxy(host: string, port: number): this { + this.#proxy = { host, port }; + return this; + } + + setHostnameVerification(enabled: boolean): this { + this.#hostnameVerification = enabled; + return this; + } + + setLogHeaders(enabled: boolean): this { + this.#logHeaders = enabled; + return this; + } + + setLogRequestBody(enabled: boolean): this { + this.#logRequestBody = enabled; + return this; + } + + setDetailedLogging(enabled: boolean): this { + this.#detailedLogging = enabled; + return this; + } + + setFollowRedirects(follow: boolean): this { + this.#followRedirects = follow; + return this; + } + + build(ApiClass: new () => T): T { + if (!this.#baseUrl) { + throw new WasapiException('baseUrl is required. Call setBaseUrl() before build().'); + } + + const config: ClientConfig = { + baseUrl: this.#baseUrl, + headers: this.#headers, + timeout: this.#timeout, + proxy: this.#proxy, + hostnameVerification: this.#hostnameVerification, + logHeaders: this.#logHeaders, + logRequestBody: this.#logRequestBody, + detailedLogging: this.#detailedLogging, + followRedirects: this.#followRedirects, + }; + + const client = new WasapiClient(config); + return client.build(ApiClass); + } + + /** + * Returns a raw WasapiClient instance for direct execute() calls + * without needing a decorated API class. + */ + buildRaw(): WasapiClient { + if (!this.#baseUrl) { + throw new WasapiException('baseUrl is required. Call setBaseUrl() before buildRaw().'); + } + + const config: ClientConfig = { + baseUrl: this.#baseUrl, + headers: this.#headers, + timeout: this.#timeout, + proxy: this.#proxy, + hostnameVerification: this.#hostnameVerification, + logHeaders: this.#logHeaders, + logRequestBody: this.#logRequestBody, + detailedLogging: this.#detailedLogging, + followRedirects: this.#followRedirects, + }; + + return new WasapiClient(config); + } + }; +} diff --git a/src/collections/ResponsePair.ts b/src/collections/ResponsePair.ts new file mode 100644 index 0000000..ac4d58a --- /dev/null +++ b/src/collections/ResponsePair.ts @@ -0,0 +1,13 @@ +export class ResponsePair { + readonly response: R; + readonly errorBody: E | null; + + constructor(response: R, errorBody: E | null) { + this.response = response; + this.errorBody = errorBody; + } + + isError(): boolean { + return this.errorBody !== null; + } +} diff --git a/src/decorators/http.ts b/src/decorators/http.ts new file mode 100644 index 0000000..37196a0 --- /dev/null +++ b/src/decorators/http.ts @@ -0,0 +1,79 @@ +import { HttpMethod } from '../models/types'; + +interface HttpMetadataEntry { + method: HttpMethod | string; + path: string; + hasBody?: boolean; +} + +/** + * Metadata storage for HTTP decorator registration. + * Keyed by class prototype โ†’ method name โ†’ metadata. + */ +class HttpMetadataStore { + private store = new Map>(); + + set(prototype: object, methodName: string, entry: HttpMetadataEntry): void { + if (!this.store.has(prototype)) { + this.store.set(prototype, new Map()); + } + this.store.get(prototype)!.set(methodName, entry); + } + + get(prototype: object, methodName: string): HttpMetadataEntry | undefined { + return this.store.get(prototype)?.get(methodName); + } +} + +export const httpMetadata = new HttpMetadataStore(); + +/** + * Creates a TC39 Stage 3 method decorator for a standard HTTP method. + */ +function createHttpDecorator(method: HttpMethod) { + return (path: string) => { + return (_target: unknown, context: ClassMethodDecoratorContext) => { + context.addInitializer(function (this: unknown) { + httpMetadata.set( + Object.getPrototypeOf(this as object) as object, + String(context.name), + { method, path }, + ); + }); + }; + }; +} + +/** `@GET('/path')` โ€” HTTP GET request. Args: (pathParams?, queryParams?, options?) */ +export const GET = createHttpDecorator(HttpMethod.GET); + +/** `@POST('/path')` โ€” HTTP POST request. Args: (body?, pathParams?, queryParams?, options?) */ +export const POST = createHttpDecorator(HttpMethod.POST); + +/** `@PUT('/path')` โ€” HTTP PUT request. Args: (body?, pathParams?, queryParams?, options?) */ +export const PUT = createHttpDecorator(HttpMethod.PUT); + +/** `@DELETE('/path')` โ€” HTTP DELETE request. Args: (pathParams?, queryParams?, options?) */ +export const DELETE = createHttpDecorator(HttpMethod.DELETE); + +/** `@PATCH('/path')` โ€” HTTP PATCH request. Args: (body?, pathParams?, queryParams?, options?) */ +export const PATCH = createHttpDecorator(HttpMethod.PATCH); + +/** + * `@HTTP('PURGE', '/cache/:key')` โ€” Custom HTTP method decorator. + * + * @param method - The HTTP method string (e.g., 'PURGE', 'COPY', 'LOCK'). + * @param path - The URL path template. + * @param hasBody - Whether the first argument is a request body. Default: false. + */ +export function HTTP(method: string, path: string, hasBody = false) { + return (_target: unknown, context: ClassMethodDecoratorContext) => { + context.addInitializer(function (this: unknown) { + httpMetadata.set( + Object.getPrototypeOf(this as object) as object, + String(context.name), + { method, path, hasBody }, + ); + }); + }; +} diff --git a/src/exceptions/FailedCallException.ts b/src/exceptions/FailedCallException.ts new file mode 100644 index 0000000..a210d32 --- /dev/null +++ b/src/exceptions/FailedCallException.ts @@ -0,0 +1,15 @@ +import { WasapiException } from './WasapiException'; + +export class FailedCallException extends WasapiException { + readonly statusCode: number; + readonly responseBody: string; + readonly url: string; + + constructor(message: string, statusCode: number, responseBody: string, url: string) { + super(message); + this.name = 'FailedCallException'; + this.statusCode = statusCode; + this.responseBody = responseBody; + this.url = url; + } +} diff --git a/src/exceptions/WasapiException.ts b/src/exceptions/WasapiException.ts new file mode 100644 index 0000000..9d2ce4e --- /dev/null +++ b/src/exceptions/WasapiException.ts @@ -0,0 +1,6 @@ +export class WasapiException extends Error { + constructor(message: string) { + super(message); + this.name = 'WasapiException'; + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..5073086 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,20 @@ +// Client +export { WasapiClient } from './client/WasapiClient'; + +// Models +export { ApiCall } from './models/ApiCall'; +export { ApiResponse } from './models/ApiResponse'; +export type { HttpMethod, RequestConfig, ClientConfig, CallOptions } from './models/types'; + +// Decorators +export { GET, POST, PUT, DELETE, PATCH, HTTP } from './decorators/http'; + +// Collections +export { ResponsePair } from './collections/ResponsePair'; + +// Exceptions +export { FailedCallException } from './exceptions/FailedCallException'; +export { WasapiException } from './exceptions/WasapiException'; + +// Logger +export { log, createLogger } from './logger/Logger'; diff --git a/src/logger/Logger.ts b/src/logger/Logger.ts new file mode 100644 index 0000000..1efaca9 --- /dev/null +++ b/src/logger/Logger.ts @@ -0,0 +1,36 @@ +import Debug from "debug"; + +const PREFIX = 'wasapi'; + +const logsDisabled = process.env.WASAPI_DEBUG === 'false'; + +if (!logsDisabled && !process.env.DEBUG) { + Debug.enable(`${PREFIX}:*`); +} + +const NAMESPACE_PAD = 9; + +export function createLogger(namespace: string): Debug.Debugger { + const padded = namespace.padEnd(NAMESPACE_PAD); + return Debug(`${PREFIX}:${padded}`); +} + +export const logger = (type: string): Debug.Debugger => { + const log = createLogger(`${type}`); + log.color = { + info: '75', + warn: '214', + error: '203', + success: '78', + important: '177', + }[type] || log.color; + return log; +}; + +export const log = { + info: logger('info'), + warn: logger('warn'), + error: logger('error'), + success: logger('success'), + important: logger('important'), +}; diff --git a/src/models/ApiCall.ts b/src/models/ApiCall.ts new file mode 100644 index 0000000..57bf81e --- /dev/null +++ b/src/models/ApiCall.ts @@ -0,0 +1,193 @@ +import type { WasapiClient } from '../client/WasapiClient'; +import { RequestConfig } from './types'; +import { ApiResponse } from './ApiResponse'; +import { ResponsePair } from '../collections/ResponsePair'; +import { FailedCallException } from '../exceptions/FailedCallException'; +import { WasapiException } from '../exceptions/WasapiException'; +import { log } from '../logger/Logger'; + +export class ApiCall { + private readonly client: WasapiClient; + private readonly config: RequestConfig; + + constructor(client: WasapiClient, config: RequestConfig) { + this.client = client; + this.config = { ...config }; + } + + /** Create an independent copy of this call for retry/polling. */ + clone(): ApiCall { + return new ApiCall(this.client, { ...this.config }); + } + + get method(): string { + return typeof this.config.method === 'string' + ? this.config.method + : this.config.method; + } + + get path(): string { + return this.config.path; + } + + /** + * Execute the request and return the parsed body. + * + * @param strict - If true, throws FailedCallException on non-2xx. Default: false. + * @param printBody - If true, logs the response body. Default: false. + * @param errorModels - Constructor functions to try deserializing the error body. + * @returns The parsed response body, or null in lenient mode on failure. + */ + async perform(strict = false, printBody = false, ...errorModels: Array unknown>): Promise { + const response = await this.client.execute(this.config); + + if (printBody && response.rawBody) { + log.info('Response body: %s', response.rawBody); + } + + if (response.isSuccessful()) { + return response.body; + } + + // Failed response + if (strict) { + throw new FailedCallException( + `${this.config.method} ${this.config.path} failed with ${response.status} ${response.statusText}`, + response.status, + response.rawBody, + this.config.path, + ); + } + + // Lenient mode โ€” try to deserialize error body + if (errorModels.length > 0 && response.rawBody) { + for (const Model of errorModels) { + try { + const parsed = JSON.parse(response.rawBody) as Record; + return Object.assign(new Model() as object, parsed) as T | null; + } catch { + continue; + } + } + } + + return null; + } + + /** + * Execute and return the full ApiResponse wrapper. + */ + async getResponse(strict = false, printBody = false): Promise> { + const response = await this.client.execute(this.config); + + if (printBody && response.rawBody) { + log.info('Response body: %s', response.rawBody); + } + + if (!response.isSuccessful() && strict) { + throw new FailedCallException( + `${this.config.method} ${this.config.path} failed with ${response.status} ${response.statusText}`, + response.status, + response.rawBody, + this.config.path, + ); + } + + return response; + } + + /** + * Execute and return a ResponsePair with typed error deserialization. + */ + async getResponsePair(ErrorClass: new () => E): Promise, E | null>> { + const response = await this.client.execute(this.config); + + if (response.isSuccessful()) { + return new ResponsePair(response, null); + } + + const errorBody = response.errorBody(ErrorClass); + return new ResponsePair(response, errorBody); + } + + /** + * Poll until the response returns the expected HTTP status code. + * + * @param expectedCode - The HTTP status code to wait for. + * @param timeout - Maximum time to wait in milliseconds. + * @param interval - Polling interval in milliseconds. Default: 1000. + */ + async monitorResponseCode( + expectedCode: number, + timeout: number, + interval = 1000, + ): Promise> { + const deadline = Date.now() + timeout; + + while (Date.now() < deadline) { + const response = await this.clone().client.execute(this.config); + + if (response.status === expectedCode) { + log.success('Response code matched: %d', expectedCode); + return response; + } + + log.info('Waiting for %d, got %d โ€” retrying in %dms', expectedCode, response.status, interval); + await sleep(interval); + } + + throw new WasapiException( + `Timed out after ${timeout}ms waiting for response code ${expectedCode} on ${this.config.method} ${this.config.path}`, + ); + } + + /** + * Poll until a field in the response body matches the expected value. + * + * @param fieldPath - Dot-notation path to the field (e.g., 'status' or 'data.state'). + * @param expectedValue - The value to match against (compared via string coercion). + * @param timeout - Maximum time to wait in milliseconds. + * @param interval - Polling interval in milliseconds. Default: 1000. + */ + async monitorFieldValue( + fieldPath: string, + expectedValue: unknown, + timeout: number, + interval = 1000, + ): Promise { + const deadline = Date.now() + timeout; + + while (Date.now() < deadline) { + const response = await this.clone().client.execute(this.config); + + if (response.body !== null) { + const actual = getNestedField(response.body, fieldPath); + if (String(actual) === String(expectedValue)) { + log.success('Field "%s" matched: %s', fieldPath, expectedValue); + return response.body; + } + log.info('Field "%s" = %s, expected %s โ€” retrying in %dms', fieldPath, actual, expectedValue, interval); + } + + await sleep(interval); + } + + throw new WasapiException( + `Timed out after ${timeout}ms waiting for field "${fieldPath}" to equal "${expectedValue}" on ${this.config.method} ${this.config.path}`, + ); + } +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function getNestedField(obj: unknown, path: string): unknown { + const parts = path.split('.'); + let current: unknown = obj; + for (const part of parts) { + if (current === null || current === undefined) return undefined; + current = (current as Record)[part]; + } + return current; +} diff --git a/src/models/ApiResponse.ts b/src/models/ApiResponse.ts new file mode 100644 index 0000000..dd6b280 --- /dev/null +++ b/src/models/ApiResponse.ts @@ -0,0 +1,68 @@ +export class ApiResponse { + readonly status: number; + readonly statusText: string; + readonly headers: Record; + readonly ok: boolean; + readonly body: T | null; + readonly rawBody: string; + + constructor( + status: number, + statusText: string, + headers: Record, + ok: boolean, + body: T | null, + rawBody: string, + ) { + this.status = status; + this.statusText = statusText; + this.headers = headers; + this.ok = ok; + this.body = body; + this.rawBody = rawBody; + } + + isSuccessful(): boolean { + return this.ok; + } + + errorBody(ErrorClass?: new () => E): E | null { + if (this.ok) return null; + if (!this.rawBody) return null; + + try { + const parsed = JSON.parse(this.rawBody) as Record; + if (ErrorClass) { + return Object.assign(new ErrorClass(), parsed) as E; + } + return parsed as unknown as E; + } catch { + return null; + } + } + + static async fromFetch(res: globalThis.Response): Promise> { + const headers: Record = {}; + res.headers.forEach((value, key) => { + headers[key] = value; + }); + + const rawBody = await res.text(); + let body: T | null = null; + + try { + body = JSON.parse(rawBody) as T; + } catch { + // Non-JSON response โ€” body stays null, rawBody has the text + } + + return new ApiResponse( + res.status, + res.statusText, + headers, + res.ok, + body, + rawBody, + ); + } +} diff --git a/src/models/types.ts b/src/models/types.ts new file mode 100644 index 0000000..fd424ff --- /dev/null +++ b/src/models/types.ts @@ -0,0 +1,37 @@ +export enum HttpMethod { + GET = 'GET', + POST = 'POST', + PUT = 'PUT', + DELETE = 'DELETE', + PATCH = 'PATCH', + OPTIONS = 'OPTIONS', + HEAD = 'HEAD', +} + +export interface RequestConfig { + method: HttpMethod | string; + path: string; + body?: unknown; + headers?: Record; + queryParams?: Record; + pathParams?: Record; + formData?: FormData; + timeout?: number; +} + +export interface ClientConfig { + baseUrl: string; + headers: Record; + timeout: number; + proxy: { host: string; port: number } | null; + hostnameVerification: boolean; + logHeaders: boolean; + logRequestBody: boolean; + detailedLogging: boolean; + followRedirects: boolean; +} + +export interface CallOptions { + headers?: Record; + timeout?: number; +} diff --git a/test-coverage-report.txt b/test-coverage-report.txt new file mode 100644 index 0000000..4eddd0e --- /dev/null +++ b/test-coverage-report.txt @@ -0,0 +1,26 @@ + +=== API COVERAGE REPORT === + +ApiCall: 6/6 + [x] clone + [x] perform + [x] getResponse + [x] getResponsePair + [x] monitorResponseCode + [x] monitorFieldValue + +ApiResponse: 3/3 + [x] isSuccessful + [x] errorBody + [x] fromFetch + +ResponsePair: 1/1 + [x] isError + +WasapiClient: 2/2 + [x] execute + [x] build + +OVERALL: 12/12 (100.0%) +THRESHOLD: 100% +STATUS: PASSED \ No newline at end of file diff --git a/tests/book-hive.test.ts b/tests/book-hive.test.ts new file mode 100644 index 0000000..9991ae1 --- /dev/null +++ b/tests/book-hive.test.ts @@ -0,0 +1,474 @@ +import { WasapiClient, GET, POST, PUT, DELETE, ApiCall, ApiResponse, ResponsePair, FailedCallException } from '../src/index'; + +// โ”€โ”€ Response Models โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +interface AuthResponse { + token: string; + userId: string; + username: string; + email: string; + balance: number; +} + +interface Book { + id: string; + title: string; + author: string; + genre: string; + description: string; + price: number; + coverImage: string; + stock: number; + isbn: string; +} + +interface Page { + content: T[]; + totalPages: number; + totalElements: number; + first: boolean; + last: boolean; + size: number; + number: number; + numberOfElements: number; + empty: boolean; +} + +interface CartItem { + id: string; + userId: string; + bookId: string; + quantity: number; + addedAt: string; +} + +interface Order { + id: string; + userId: string; + items: { bookId: string; quantity: number; priceAtPurchase: number }[]; + totalPrice: number; + status: string; + purchasedAt: string; +} + +interface MarketplaceListing { + id: string; + sellerId: string; + bookId: string; + condition: string; + price: number; + listedAt: string; + status: string; +} + +interface HealthResponse { + status: string; + db: string; +} + +interface StatusResponse { + status: string; +} + +class ErrorBody { + message?: string; + error?: string; + status?: number; +} + +// โ”€โ”€ API Definitions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class HealthApi { + @GET('/api/health') + health(): ApiCall { return null!; } + + @POST('/api/seed') + seed(): ApiCall { return null!; } + + @POST('/api/reset') + reset(): ApiCall { return null!; } +} + +class AuthApi { + @POST('/api/auth/signup') + signup(body: { username: string; email: string; password: string }): ApiCall { return null!; } + + @POST('/api/auth/login') + login(body: { email: string; password: string }): ApiCall { return null!; } + + @GET('/api/auth/me') + me(): ApiCall { return null!; } + + @POST('/api/auth/logout') + logout(): ApiCall { return null!; } +} + +class BooksApi { + @GET('/api/books') + list(pathParams?: Record, queryParams?: Record): ApiCall> { return null!; } + + @GET('/api/books/:id') + getById(pathParams: { id: string }): ApiCall { return null!; } +} + +class CartApi { + @GET('/api/cart') + get(): ApiCall { return null!; } + + @POST('/api/cart/items') + addItem(body: { bookId: string; quantity: number }): ApiCall { return null!; } + + @PUT('/api/cart/items/:id') + updateItem(body: { quantity: number }, pathParams: { id: string }): ApiCall { return null!; } + + @DELETE('/api/cart/items/:id') + removeItem(pathParams: { id: string }): ApiCall { return null!; } + + @DELETE('/api/cart') + clear(): ApiCall { return null!; } +} + +class OrdersApi { + @POST('/api/orders') + checkout(body?: unknown): ApiCall { return null!; } + + @GET('/api/orders') + list(): ApiCall { return null!; } + + @GET('/api/orders/:id') + getById(pathParams: { id: string }): ApiCall { return null!; } + + @POST('/api/orders/:id/return') + returnOrder(body: unknown, pathParams: { id: string }): ApiCall { return null!; } +} + +class MarketplaceApi { + @GET('/api/marketplace') + list(): ApiCall { return null!; } + + @POST('/api/marketplace/listings') + create(body: { bookId: string; condition: string; price: number }): ApiCall { return null!; } + + @POST('/api/marketplace/listings/:id/buy') + buy(body: unknown, pathParams: { id: string }): ApiCall { return null!; } + + @DELETE('/api/marketplace/listings/:id') + cancel(pathParams: { id: string }): ApiCall { return null!; } +} + +// โ”€โ”€ Test Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const BASE_URL = 'http://localhost:8080'; + +function buildPublicApi(ApiClass: new () => T): T { + return new WasapiClient.Builder() + .setBaseUrl(BASE_URL) + .setLogHeaders(false) + .build(ApiClass); +} + +function buildAuthApi(ApiClass: new () => T, token: string): T { + return new WasapiClient.Builder() + .setBaseUrl(BASE_URL) + .setHeaders({ Authorization: `Bearer ${token}` }) + .setLogHeaders(false) + .build(ApiClass); +} + +// โ”€โ”€ Tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function main() { + let passed = 0; + let failed = 0; + + async function test(name: string, fn: () => Promise) { + try { + await fn(); + console.log(` โœ“ ${name}`); + passed++; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.log(` โœ— ${name}`); + console.log(` ${msg}`); + failed++; + } + } + + function assert(condition: boolean, message: string) { + if (!condition) throw new Error(`Assertion failed: ${message}`); + } + + // โ”€โ”€ Setup โ”€โ”€ + const healthApi = buildPublicApi(HealthApi); + const booksApi = buildPublicApi(BooksApi); + + // Reset DB + await healthApi.reset().perform(true, false); + + // โ”€โ”€ Health & Seed โ”€โ”€ + console.log('\nโ”€โ”€ Health & Seed โ”€โ”€'); + + await test('GET /api/health โ€” perform() returns parsed body', async () => { + const body = await healthApi.health().perform(true); + assert(body !== null, 'body should not be null'); + assert(body!.status === 'healthy', `expected "healthy", got "${body!.status}"`); + assert(body!.db === 'connected', `expected "connected", got "${body!.db}"`); + }); + + await test('POST /api/seed โ€” perform() strict mode', async () => { + const body = await healthApi.seed().perform(true); + assert(body !== null, 'body should not be null'); + assert(body!.status === 'seeded', `expected "seeded", got "${body!.status}"`); + }); + + // โ”€โ”€ ApiCall.getResponse() โ”€โ”€ + console.log('\nโ”€โ”€ ApiCall.getResponse() โ”€โ”€'); + + await test('getResponse() returns full ApiResponse wrapper', async () => { + const resp = await healthApi.health().getResponse(); + assert(resp.status === 200, `expected 200, got ${resp.status}`); + assert(resp.ok === true, 'expected ok to be true'); + assert(resp.body !== null, 'body should not be null'); + assert(resp.headers['content-type']?.includes('application/json') === true, 'expected JSON content-type'); + assert(typeof resp.rawBody === 'string', 'rawBody should be string'); + assert(resp.isSuccessful() === true, 'isSuccessful should return true'); + }); + + // โ”€โ”€ ApiCall.clone() โ”€โ”€ + console.log('\nโ”€โ”€ ApiCall.clone() โ”€โ”€'); + + await test('clone() creates independent copy that executes independently', async () => { + const call1 = healthApi.health(); + const call2 = call1.clone(); + const [resp1, resp2] = await Promise.all([call1.perform(true), call2.perform(true)]); + assert(resp1!.status === 'healthy', 'original should work'); + assert(resp2!.status === 'healthy', 'clone should work'); + }); + + // โ”€โ”€ Books โ€” GET with path params and query params โ”€โ”€ + console.log('\nโ”€โ”€ Books โ”€โ”€'); + + await test('GET /api/books โ€” list with pagination query params', async () => { + const body = await booksApi.list(undefined, { page: '0', size: '5' }).perform(true); + assert(body !== null, 'body should not be null'); + assert(body!.content.length <= 5, `expected at most 5 items, got ${body!.content.length}`); + assert(body!.totalElements === 50, `expected 50 total, got ${body!.totalElements}`); + assert(body!.size === 5, `expected page size 5, got ${body!.size}`); + }); + + await test('GET /api/books โ€” search by query param', async () => { + const body = await booksApi.list(undefined, { query: 'Mockingbird' }).perform(true); + assert(body !== null, 'body should not be null'); + assert(body!.content.length > 0, 'expected at least 1 result'); + assert(body!.content[0].title.includes('Mockingbird'), `expected title with "Mockingbird"`); + }); + + await test('GET /api/books/:id โ€” path param substitution', async () => { + const book = await booksApi.getById({ id: 'book-001' }).perform(true); + assert(book !== null, 'body should not be null'); + assert(book!.id === 'book-001', `expected "book-001", got "${book!.id}"`); + assert(typeof book!.title === 'string', 'title should be string'); + assert(typeof book!.price === 'number', 'price should be number'); + }); + + // โ”€โ”€ Strict mode โ€” FailedCallException โ”€โ”€ + console.log('\nโ”€โ”€ Strict / Lenient Modes โ”€โ”€'); + + await test('strict mode throws FailedCallException on 404', async () => { + try { + await booksApi.getById({ id: 'nonexistent-book' }).perform(true); + assert(false, 'should have thrown'); + } catch (err) { + assert(err instanceof FailedCallException, `expected FailedCallException, got ${(err as Error).constructor.name}`); + assert((err as FailedCallException).statusCode === 404, `expected 404, got ${(err as FailedCallException).statusCode}`); + } + }); + + await test('lenient mode returns null on 404', async () => { + const result = await booksApi.getById({ id: 'nonexistent-book' }).perform(false); + assert(result === null, 'expected null in lenient mode'); + }); + + // โ”€โ”€ ApiCall.getResponsePair() โ”€โ”€ + console.log('\nโ”€โ”€ getResponsePair() โ”€โ”€'); + + await test('getResponsePair returns ResponsePair with error body on failure', async () => { + const pair = await booksApi.getById({ id: 'nonexistent' }).getResponsePair(ErrorBody); + assert(pair instanceof ResponsePair, 'should be ResponsePair'); + assert(pair.response.status === 404, `expected 404, got ${pair.response.status}`); + assert(pair.isError() || pair.errorBody === null, 'should have error or null errorBody'); + }); + + await test('getResponsePair returns no error on success', async () => { + const pair = await healthApi.health().getResponsePair(ErrorBody); + assert(pair.response.ok === true, 'response should be ok'); + assert(pair.errorBody === null, 'errorBody should be null on success'); + assert(pair.isError() === false, 'isError should be false'); + }); + + // โ”€โ”€ Auth โ€” signup, login, me โ”€โ”€ + console.log('\nโ”€โ”€ Auth โ”€โ”€'); + + const authApi = buildPublicApi(AuthApi); + let token1: string; + let userId1: string; + + await test('POST /api/auth/login โ€” get JWT token', async () => { + const body = await authApi.login({ email: 'testuser1@bookhive.test', password: 'Test1234!' }).perform(true); + assert(body !== null, 'body should not be null'); + assert(typeof body!.token === 'string', 'token should be string'); + assert(body!.email === 'testuser1@bookhive.test', 'email should match'); + token1 = body!.token; + userId1 = body!.userId; + }); + + await test('GET /api/auth/me โ€” authenticated request with JWT header', async () => { + const authedAuth = buildAuthApi(AuthApi, token1); + const body = await authedAuth.me().perform(true); + assert(body !== null, 'body should not be null'); + assert(body!.userId === userId1, `userId should match`); + assert(body!.email === 'testuser1@bookhive.test', 'email should match'); + }); + + // โ”€โ”€ Cart โ€” full CRUD โ”€โ”€ + console.log('\nโ”€โ”€ Cart โ”€โ”€'); + + const cartApi = buildAuthApi(CartApi, token1); + let cartItemId: string; + + await test('POST /api/cart/items โ€” add item to cart', async () => { + const item = await cartApi.addItem({ bookId: 'book-001', quantity: 1 }).perform(true); + assert(item !== null, 'body should not be null'); + assert(item!.bookId === 'book-001', 'bookId should match'); + assert(item!.quantity === 1, 'quantity should be 1'); + cartItemId = item!.id; + }); + + await test('GET /api/cart โ€” list cart items', async () => { + const items = await cartApi.get().perform(true); + assert(items !== null, 'body should not be null'); + assert(items!.length >= 1, 'cart should have at least 1 item'); + }); + + await test('PUT /api/cart/items/:id โ€” update quantity', async () => { + const updated = await cartApi.updateItem({ quantity: 2 }, { id: cartItemId }).perform(true); + assert(updated !== null, 'body should not be null'); + assert(updated!.quantity === 2, `expected quantity 2, got ${updated!.quantity}`); + }); + + await test('DELETE /api/cart/items/:id โ€” remove single item', async () => { + const resp = await cartApi.removeItem({ id: cartItemId }).getResponse(true); + assert(resp.status === 200, `expected 200, got ${resp.status}`); + }); + + await test('DELETE /api/cart โ€” clear entire cart', async () => { + await cartApi.addItem({ bookId: 'book-002', quantity: 1 }).perform(true); + const resp = await cartApi.clear().getResponse(true); + assert(resp.status === 200, `expected 200, got ${resp.status}`); + const items = await cartApi.get().perform(true); + assert(items!.length === 0, `expected empty cart, got ${items!.length} items`); + }); + + // โ”€โ”€ Orders โ€” checkout + return โ”€โ”€ + console.log('\nโ”€โ”€ Orders โ”€โ”€'); + + const ordersApi = buildAuthApi(OrdersApi, token1); + let orderId: string; + + await test('POST /api/orders โ€” checkout creates order', async () => { + // Add item to cart first + await cartApi.addItem({ bookId: 'book-003', quantity: 1 }).perform(true); + const order = await ordersApi.checkout({}).perform(true); + assert(order !== null, 'body should not be null'); + assert(order!.status === 'COMPLETED', `expected COMPLETED, got ${order!.status}`); + assert(order!.items.length === 1, 'should have 1 item'); + assert(order!.items[0].bookId === 'book-003', 'bookId should match'); + orderId = order!.id; + }); + + await test('GET /api/orders โ€” list user orders', async () => { + const orders = await ordersApi.list().perform(true); + assert(orders !== null, 'body should not be null'); + assert(orders!.length >= 1, 'should have at least 1 order'); + }); + + await test('GET /api/orders/:id โ€” get order by ID', async () => { + const order = await ordersApi.getById({ id: orderId }).perform(true); + assert(order !== null, 'body should not be null'); + assert(order!.id === orderId, 'orderId should match'); + }); + + await test('POST /api/orders/:id/return โ€” return order', async () => { + const returned = await ordersApi.returnOrder({}, { id: orderId }).perform(true); + assert(returned !== null, 'body should not be null'); + assert(returned!.status === 'RETURNED', `expected RETURNED, got ${returned!.status}`); + }); + + // โ”€โ”€ Marketplace โ”€โ”€ + console.log('\nโ”€โ”€ Marketplace โ”€โ”€'); + + const marketApi1 = buildAuthApi(MarketplaceApi, token1); + let listingId: string; + + await test('POST /api/marketplace/listings โ€” create listing', async () => { + const listing = await marketApi1.create({ bookId: 'book-010', condition: 'GOOD', price: 7.99 }).perform(true); + assert(listing !== null, 'body should not be null'); + assert(listing!.status === 'ACTIVE', `expected ACTIVE, got ${listing!.status}`); + assert(listing!.condition === 'GOOD', 'condition should match'); + listingId = listing!.id; + }); + + await test('GET /api/marketplace โ€” list active listings', async () => { + const listings = await marketApi1.list().perform(true); + assert(listings !== null, 'body should not be null'); + assert(listings!.length >= 1, 'should have at least 1 listing'); + }); + + await test('POST /api/marketplace/listings/:id/buy โ€” buy listing', async () => { + // Login as user2 to buy + const user2Auth = await authApi.login({ email: 'testuser2@bookhive.test', password: 'Test1234!' }).perform(true); + const marketApi2 = buildAuthApi(MarketplaceApi, user2Auth!.token); + const bought = await marketApi2.buy({}, { id: listingId }).perform(true); + assert(bought !== null, 'body should not be null'); + assert(bought!.status === 'COMPLETED' || bought!.status === 'SOLD', `expected COMPLETED or SOLD, got ${bought!.status}`); + }); + + await test('DELETE /api/marketplace/listings/:id โ€” cancel own listing', async () => { + // Create another listing then cancel it + const listing = await marketApi1.create({ bookId: 'book-020', condition: 'FAIR', price: 5.00 }).perform(true); + const resp = await marketApi1.cancel({ id: listing!.id }).getResponse(true); + assert(resp.status === 200, `expected 200, got ${resp.status}`); + }); + + // โ”€โ”€ Polling โ€” monitorResponseCode โ”€โ”€ + console.log('\nโ”€โ”€ Polling โ”€โ”€'); + + await test('monitorResponseCode โ€” succeeds when code matches', async () => { + const resp = await healthApi.health().monitorResponseCode(200, 5000, 500); + assert(resp.status === 200, `expected 200, got ${resp.status}`); + }); + + await test('monitorFieldValue โ€” polls until field matches', async () => { + const body = await healthApi.health().monitorFieldValue('status', 'healthy', 5000, 500); + assert(body !== null, 'body should not be null'); + assert(body!.status === 'healthy', `expected "healthy"`); + }); + + // โ”€โ”€ ApiResponse.errorBody() โ”€โ”€ + console.log('\nโ”€โ”€ ApiResponse.errorBody() โ”€โ”€'); + + await test('errorBody() returns null on success', async () => { + const resp = await healthApi.health().getResponse(); + const err = resp.errorBody(ErrorBody); + assert(err === null, 'errorBody should be null on success'); + }); + + // โ”€โ”€ Summary โ”€โ”€ + console.log(`\nโ”€โ”€ Results: ${passed} passed, ${failed} failed โ”€โ”€\n`); + process.exit(failed > 0 ? 1 : 0); +} + +main().catch(err => { + console.error('Fatal:', err); + process.exit(1); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0c81c9c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "node", + "declaration": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "tests"] +}