From c41392e055296360bd90221f8f1bba984f89a734 Mon Sep 17 00:00:00 2001 From: Kirill Mokevnin Date: Wed, 14 Oct 2026 16:16:29 -0400 Subject: [PATCH 1/4] =?UTF-8?q?build(deps):=20=D0=BE=D0=B1=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=B8=D1=82=D1=8C=20=D0=B7=D0=B0=D0=B2=D0=B8=D1=81=D0=B8?= =?UTF-8?q?=D0=BC=D0=BE=D1=81=D1=82=D0=B8,=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D1=82=D1=8C=20vitest,=20oxlint=20=D0=B8=20oxfmt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- Dockerfile | 2 +- package.json | 37 +- pnpm-lock.yaml | 1774 ++++++++++++++++++++++++++++++++++++++---------- 3 files changed, 1422 insertions(+), 391 deletions(-) diff --git a/Dockerfile b/Dockerfile index ba8ca49..23eae47 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ RUN apk add --no-cache make caddy # corepack из образов Node 26 убран, поэтому pnpm ставится напрямую. Версия # совпадает с полем packageManager, чтобы образ и разработка не расходились. -RUN npm install -g pnpm@12.6.0 +RUN npm install -g pnpm@12.11.2 # .pnpmfile.cjs участвует в разрешении зависимостей, и без него # --frozen-lockfile отвергает лок как несовпадающий. diff --git a/package.json b/package.json index 95d8b66..57a3563 100644 --- a/package.json +++ b/package.json @@ -7,28 +7,31 @@ }, "devDependencies": { "@hey-api/openapi-ts": "^0.99.0", - "npm-check-updates": "^23.1.0", - "typescript": "^7.0.2" + "npm-check-updates": "^23.1.1", + "oxfmt": "^0.73.0", + "oxlint": "^1.88.0", + "typescript": "^7.0.2", + "vitest": "^5.0.4" }, "private": true, "dependencies": { - "@fastify/cookie": "^11.1.2", - "@fastify/formbody": "^9.0.0", + "@fastify/cookie": "^11.1.3", + "@fastify/formbody": "^9.0.1", "@fastify/multipart": "^10.1.3", - "@fastify/static": "^10.1.5", - "@fastify/swagger": "^9.9.0", - "@fastify/swagger-ui": "^6.1.1", - "@typespec/compiler": "^1.16.0", - "@typespec/http": "^1.16.0", - "@typespec/openapi": "^1.16.0", - "@typespec/openapi3": "^1.16.0", - "@typespec/rest": "^0.86.0", - "@typespec/versioning": "^0.86.0", - "fastify": "^5.12.5", + "@fastify/static": "^10.1.6", + "@fastify/swagger": "^9.9.2", + "@fastify/swagger-ui": "^6.1.2", + "@typespec/compiler": "^1.17.0", + "@typespec/http": "^1.17.0", + "@typespec/openapi": "^1.17.0", + "@typespec/openapi3": "^1.17.0", + "@typespec/rest": "^0.87.0", + "@typespec/versioning": "^0.87.0", + "fastify": "^5.12.7", "fastify-allow": "^2.1.1", - "fastify-cli": "^8.0.2", + "fastify-cli": "^8.0.3", "fastify-openapi-glue": "^4.11.5", - "fastify-plugin": "^6.0.0" + "fastify-plugin": "^6.0.1" }, - "packageManager": "pnpm@12.6.0" + "packageManager": "pnpm@12.11.2" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b117a8..e7f9f8f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.6.0 - version: 12.6.0 + specifier: 12.11.2 + version: 12.11.2 packages: - '@pnpm/exe.android-arm64@12.6.0': - resolution: {integrity: sha512-kviIHft9h02q+7N2In7tSL9T/HRUSGwYmU74YPtsWU5ee7vgqPyC0JT6RRX6miW+NxX1CvHgQxg69qq1qTLJYQ==} + '@pnpm/exe.android-arm64@12.11.2': + resolution: {integrity: sha512-owLw13OfPkEoY+hLBNeeG+dCFhywr7q2d/EHPz4mNL2RhKnv8gNwMARkjkiDQu234aItVwFNDeGnmQXm+ikkAg==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.6.0': - resolution: {integrity: sha512-CT8aJKLq2mtZFE71pr4E5Z2xHL8uGnRGr+NclDqsXlVF4SVcQ2QAs14mWi0C8gjT4hbmi+Q0lJRRIkMAWfjjng==} + '@pnpm/exe.android-x64@12.11.2': + resolution: {integrity: sha512-GYPkVebl4iokOA/4MPSpGoUVfFlsth7uqOCe1XiaiM23LAdvUOMJBObqHJijr89ZqjwHm9pO6vO45GRwJ2U2qA==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.6.0': - resolution: {integrity: sha512-rafpVkjzugKBMxSvGQd5wK1x5eOTkD/+lcJIFHeQhvd+BJ8kDisOOvwhQDrpGd4vd2Fx+hhW0P2Ptt638OlhFg==} + '@pnpm/exe.darwin-arm64@12.11.2': + resolution: {integrity: sha512-n9M4On66y8FDxUAKgMO1FXt0qihyG4e+56p3/F0uo8Bx9KplW8BwKVnXigzCJ8aNM6sVVPkOE6M9XXbeVIxnZA==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.6.0': - resolution: {integrity: sha512-72Jpuv1m24gI8zUNcaylYpBWEhBzz1VPhnDIU2GYM2eRP+KsOqVLwV78i+JrihI39zY2NNXmaN4eAKX1inpaDQ==} + '@pnpm/exe.darwin-x64@12.11.2': + resolution: {integrity: sha512-oSW7n3sBT0c0cyk4kj10T2Y6Mpm+OFEiCAWozgX039tdoI6yqJF1uaFD3sw7ugLdu6VV3+uY0pa1TUZIPdxDWA==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.6.0': - resolution: {integrity: sha512-LON4QgNy1w/XF4uySZIyC/5hEB0oGdelXsY9tSKqstER+2j2X2oHz67skD11RRd/HWFRtMr64shQZ4I9ZoZ0zg==} + '@pnpm/exe.freebsd-x64@12.11.2': + resolution: {integrity: sha512-qbzc3KoDpa/Lkf8vs3jnDe6dUUbso+6eLIyssYFMiliLpYNxn8dirk11YoNnOQcdHVB50v5RFls1Yid6ttT5pg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.6.0': - resolution: {integrity: sha512-BdDpX+DeaMUc5x6WNcE6FkmFy36NwrBbBvwsZU737Zt3u1fTY+iKFPchjEQNCM4rT6q6xvF2oN3ZwExGsJCkxQ==} + '@pnpm/exe.linux-arm64-musl@12.11.2': + resolution: {integrity: sha512-uMEYDwKRktGwANubQ+kYCwmhNwg3Ik6qK89ahsbQ5cP30R/5FVJb/T99vr9XRUBC/A7mZI6eeT92oyVas9ACyg==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.6.0': - resolution: {integrity: sha512-8h2sNoIhHDpHDYqZZCh9PXr6krqW10btb0GHVGuw710muDHlFAHMtUG2ogmaHge6zJG1xgY4Y7mwX5g/LWvrYg==} + '@pnpm/exe.linux-arm64@12.11.2': + resolution: {integrity: sha512-6e8ZAQUwFXAWHHHv5FesWqf/kEK04wuitBv5f/gxzkC0DiBfaixr+f+6JQede8SF4Ji5LcMwVFJbzAtffpWxEg==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.6.0': - resolution: {integrity: sha512-ld6xgcEhFCFrsVsJhbmdmR714PnsEspO/Ij8w0/D7OSiz2hYGu3fZd5tKRxOI5m6H6Gl0/RRFAPncTHbsgXVjQ==} + '@pnpm/exe.linux-ppc64@12.11.2': + resolution: {integrity: sha512-Ui8A4nepkVupx2VpuFfysEluMvWK6gTB5rgVts7Ss/+8dj0RD28x3F/seNeH/zSypgC6yKdV6qsKVQh/uffTuQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.6.0': - resolution: {integrity: sha512-HIbImydoC3J8NFt+8MXfhTCf5rO6NjCMyinSAHac0VmV8uCHw+kbQkyAzxV84rzz9ZOzmBOnj0b1N461UIYGsw==} + '@pnpm/exe.linux-riscv64@12.11.2': + resolution: {integrity: sha512-i1yLdQ5bu+wjQ+srtFoJAaY2gN/+oXHFdjul4llCeoDxHE0rTIG3R41NhHfCjyFPW4+CpbBT58933CL9sMOCnA==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.6.0': - resolution: {integrity: sha512-DjSqT7+BZ/lWcDHwNMuzsRVfyxUimh+D54Xw4tSWbdVVQhtWtNMUyI93LVnwCmsoEpar2gC3SFeAgywx37svKQ==} + '@pnpm/exe.linux-s390x@12.11.2': + resolution: {integrity: sha512-YS1/lhPjjTsN4QL50VaFZKRa3Uc/bDnhE8eKbJMFvuX06UuFS5YaiDg04APoZdfVSGqTfX27QAg7rfLMSWaqxA==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.6.0': - resolution: {integrity: sha512-31lKeGPmRE6xfV6I3VjaEGVFRei14pl/zgoFDK4w+UkbJqOuNl2Ht3O1GuqNIg3Gl14qTEb4kZRAYFHXifDx9A==} + '@pnpm/exe.linux-x64-musl@12.11.2': + resolution: {integrity: sha512-7eIxQR05fvJxCujUSKp5KTtehJ7QqrgqP/HD1+BcB/SaV2Be4XI1PEHiOgA1zxsiMIeMvTF6E4G4dYHrFjeXRg==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.6.0': - resolution: {integrity: sha512-qFWBneHJAJ73W4whtbaFOL1M/7DBC6ILHXuxc7ZPtEhfPuT1zeZiGrmKHoMAfJA+mcm6xhOFljqVTUS+00Jabw==} + '@pnpm/exe.linux-x64@12.11.2': + resolution: {integrity: sha512-FK7QKe24AYgppJwO3mpooYptduuvIGqKJPi5ac7MdnWkjUy46JBfYhCW0grnkWKC8YOf0x+vGviuQqJEj3SpQQ==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.6.0': - resolution: {integrity: sha512-OhfefXEEykZlslSUhR8PPRe6MVPV3Oink/dVfIecE5velpc7j80cZYsHlaDrtRDcm3TPwQHkaRW1SJ1efX9oFg==} + '@pnpm/exe.win32-arm64@12.11.2': + resolution: {integrity: sha512-PCTjVDdJb6VvraZxrEeFv6EqEA0hhIsAySx28e95J2Tqpi88hLpgOXgNqV0vpxq9A+b8GLQfyk/D+1UlJPv/YQ==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.6.0': - resolution: {integrity: sha512-L2tuyrD2+Imgxs3VK/ST/2L3xD6vDP0ktJQxM0TaGw7TbAQEMq+RMc8iK6Ew7Id579uzjJe8jSfJID2ZGsOHCA==} + '@pnpm/exe.win32-x64@12.11.2': + resolution: {integrity: sha512-8sgX86OvRjJ3yZs/TvGR2L31wH9ScPZ+f+geKI3ikuB0RRiK3i4LlTRO8OXN6pdRqj8epMde4312MCjj7JsJdA==} cpu: [x64] os: [win32] - pnpm@12.6.0: - resolution: {integrity: sha512-PvaPlRyxEawgS0paFvCy3fDaVqluBBPoHYVdnwtV75JnFHCQKOHNAMQFwsX7e56OxNxGd3yAXQNzwvL/AP0g7A==} + pnpm@12.11.2: + resolution: {integrity: sha512-JaUWUqGWeFr18ovabf6NxYLr76SsHRgCIc4TkCMcX7dmN4IupLbkYzznSs3LCnf3Ja8RutOBd/3t2u6BipVcuQ==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.6.0': + '@pnpm/exe.android-arm64@12.11.2': optional: true - '@pnpm/exe.android-x64@12.6.0': + '@pnpm/exe.android-x64@12.11.2': optional: true - '@pnpm/exe.darwin-arm64@12.6.0': + '@pnpm/exe.darwin-arm64@12.11.2': optional: true - '@pnpm/exe.darwin-x64@12.6.0': + '@pnpm/exe.darwin-x64@12.11.2': optional: true - '@pnpm/exe.freebsd-x64@12.6.0': + '@pnpm/exe.freebsd-x64@12.11.2': optional: true - '@pnpm/exe.linux-arm64-musl@12.6.0': + '@pnpm/exe.linux-arm64-musl@12.11.2': optional: true - '@pnpm/exe.linux-arm64@12.6.0': + '@pnpm/exe.linux-arm64@12.11.2': optional: true - '@pnpm/exe.linux-ppc64@12.6.0': + '@pnpm/exe.linux-ppc64@12.11.2': optional: true - '@pnpm/exe.linux-riscv64@12.6.0': + '@pnpm/exe.linux-riscv64@12.11.2': optional: true - '@pnpm/exe.linux-s390x@12.6.0': + '@pnpm/exe.linux-s390x@12.11.2': optional: true - '@pnpm/exe.linux-x64-musl@12.6.0': + '@pnpm/exe.linux-x64-musl@12.11.2': optional: true - '@pnpm/exe.linux-x64@12.6.0': + '@pnpm/exe.linux-x64@12.11.2': optional: true - '@pnpm/exe.win32-arm64@12.6.0': + '@pnpm/exe.win32-arm64@12.11.2': optional: true - '@pnpm/exe.win32-x64@12.6.0': + '@pnpm/exe.win32-x64@12.11.2': optional: true - pnpm@12.6.0: + pnpm@12.11.2: optionalDependencies: - '@pnpm/exe.android-arm64': 12.6.0 - '@pnpm/exe.android-x64': 12.6.0 - '@pnpm/exe.darwin-arm64': 12.6.0 - '@pnpm/exe.darwin-x64': 12.6.0 - '@pnpm/exe.freebsd-x64': 12.6.0 - '@pnpm/exe.linux-arm64': 12.6.0 - '@pnpm/exe.linux-arm64-musl': 12.6.0 - '@pnpm/exe.linux-ppc64': 12.6.0 - '@pnpm/exe.linux-riscv64': 12.6.0 - '@pnpm/exe.linux-s390x': 12.6.0 - '@pnpm/exe.linux-x64': 12.6.0 - '@pnpm/exe.linux-x64-musl': 12.6.0 - '@pnpm/exe.win32-arm64': 12.6.0 - '@pnpm/exe.win32-x64': 12.6.0 + '@pnpm/exe.android-arm64': 12.11.2 + '@pnpm/exe.android-x64': 12.11.2 + '@pnpm/exe.darwin-arm64': 12.11.2 + '@pnpm/exe.darwin-x64': 12.11.2 + '@pnpm/exe.freebsd-x64': 12.11.2 + '@pnpm/exe.linux-arm64': 12.11.2 + '@pnpm/exe.linux-arm64-musl': 12.11.2 + '@pnpm/exe.linux-ppc64': 12.11.2 + '@pnpm/exe.linux-riscv64': 12.11.2 + '@pnpm/exe.linux-s390x': 12.11.2 + '@pnpm/exe.linux-x64': 12.11.2 + '@pnpm/exe.linux-x64-musl': 12.11.2 + '@pnpm/exe.win32-arm64': 12.11.2 + '@pnpm/exe.win32-x64': 12.11.2 --- lockfileVersion: '9.0' @@ -169,76 +169,85 @@ importers: .: dependencies: '@fastify/cookie': - specifier: ^11.1.2 - version: 11.1.2 + specifier: ^11.1.3 + version: 11.1.3 '@fastify/formbody': - specifier: ^9.0.0 - version: 9.0.0 + specifier: ^9.0.1 + version: 9.0.1 '@fastify/multipart': specifier: ^10.1.3 version: 10.1.3 '@fastify/static': - specifier: ^10.1.5 - version: 10.1.5 + specifier: ^10.1.6 + version: 10.1.6 '@fastify/swagger': - specifier: ^9.9.0 - version: 9.9.0 + specifier: ^9.9.2 + version: 9.9.2 '@fastify/swagger-ui': - specifier: ^6.1.1 - version: 6.1.1 + specifier: ^6.1.2 + version: 6.1.2 '@typespec/compiler': - specifier: ^1.16.0 - version: 1.16.0 + specifier: ^1.17.0 + version: 1.17.0 '@typespec/http': - specifier: ^1.16.0 - version: 1.16.0(@typespec/compiler@1.16.0) + specifier: ^1.17.0 + version: 1.17.0(@typespec/compiler@1.17.0) '@typespec/openapi': - specifier: ^1.16.0 - version: 1.16.0(@typespec/compiler@1.16.0)(@typespec/http@1.16.0(@typespec/compiler@1.16.0)) + specifier: ^1.17.0 + version: 1.17.0(@typespec/compiler@1.17.0)(@typespec/http@1.17.0(@typespec/compiler@1.17.0)) '@typespec/openapi3': - specifier: ^1.16.0 - version: 1.16.0(@typespec/compiler@1.16.0)(@typespec/http@1.16.0(@typespec/compiler@1.16.0))(@typespec/openapi@1.16.0(@typespec/compiler@1.16.0)(@typespec/http@1.16.0(@typespec/compiler@1.16.0)))(@typespec/versioning@0.86.0(@typespec/compiler@1.16.0)) + specifier: ^1.17.0 + version: 1.17.0(@typespec/compiler@1.17.0)(@typespec/http@1.17.0(@typespec/compiler@1.17.0))(@typespec/openapi@1.17.0(@typespec/compiler@1.17.0)(@typespec/http@1.17.0(@typespec/compiler@1.17.0)))(@typespec/versioning@0.87.0(@typespec/compiler@1.17.0)) '@typespec/rest': - specifier: ^0.86.0 - version: 0.86.0(@typespec/compiler@1.16.0)(@typespec/http@1.16.0(@typespec/compiler@1.16.0)) + specifier: ^0.87.0 + version: 0.87.0(@typespec/compiler@1.17.0)(@typespec/http@1.17.0(@typespec/compiler@1.17.0)) '@typespec/versioning': - specifier: ^0.86.0 - version: 0.86.0(@typespec/compiler@1.16.0) + specifier: ^0.87.0 + version: 0.87.0(@typespec/compiler@1.17.0) fastify: - specifier: ^5.12.5 - version: 5.12.5 + specifier: ^5.12.7 + version: 5.12.7 fastify-allow: specifier: ^2.1.1 version: 2.1.1 fastify-cli: - specifier: ^8.0.2 - version: 8.0.2 + specifier: ^8.0.3 + version: 8.0.3 fastify-openapi-glue: specifier: ^4.11.5 version: 4.11.5 fastify-plugin: - specifier: ^6.0.0 - version: 6.0.0 + specifier: ^6.0.1 + version: 6.0.1 devDependencies: '@hey-api/openapi-ts': specifier: ^0.99.0 version: 0.99.0 npm-check-updates: - specifier: ^23.1.0 - version: 23.1.0 + specifier: ^23.1.1 + version: 23.1.1 + oxfmt: + specifier: ^0.73.0 + version: 0.73.0 + oxlint: + specifier: ^1.88.0 + version: 1.88.0 typescript: specifier: ^7.0.2 version: 7.0.2 + vitest: + specifier: ^5.0.4 + version: 5.0.4(vite@8.3.4(jiti@2.7.0)(yaml@2.9.1)) packages: - '@babel/code-frame@7.29.7': - resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} - engines: {node: '>=6.9.0'} + '@babel/code-frame@8.0.6': + resolution: {integrity: sha512-uMHhvmDmwFiLuiozaoRYTZHqMFuu3fem/X9JY78lohAuLoh8LSGGC8JqUzo2cVuBsBxkjMZNk56CE7ock8Xxkw==} + engines: {node: ^22.18.0 || >=24.11.0} - '@babel/helper-validator-identifier@7.29.7': - resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} - engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@8.0.6': + resolution: {integrity: sha512-HYx0YmuchRxRSpnjvcNffnPS8DCauKNmnumIuHG3jnQAVewRA7kfJGwF13ayEpSZs7s4R/oBH4eppuXcAQd5JQ==} + engines: {node: ^22.18.0 || >=24.11.0} '@fastify/accept-negotiator@2.1.0': resolution: {integrity: sha512-F3EVbzWt+xcnVaOHmWyIlpuFtbxOln7HDZQsh09MtMmMm/CipMayNt8hnIL8VQi54u2ZociDbf+iluGYkf7B1A==} @@ -249,8 +258,8 @@ packages: '@fastify/busboy@3.2.3': resolution: {integrity: sha512-fmo/IKgkenPzz3qTKG+sIUXdF/Kv83AkvvOXJHPTHJeKJbat8SsL1ihUaFaDotqoEl4UZ1Lf8Eb/3vK6eaWX2A==} - '@fastify/cookie@11.1.2': - resolution: {integrity: sha512-Dtrpk/YOGUsbRMvP/8ZqPpwnMRv0qSqodFdoQ2B589Obc7jw4s4Qla+cV72Bsm7WsZJnqlYFX/i7uSBq0xzg6g==} + '@fastify/cookie@11.1.3': + resolution: {integrity: sha512-RBhlMNsmSG6C5KGiTkhSXBF75xIrUMSytDDBcpnVewhJ8XVYnJTu+SV6FYKXClIRsBuRjOfKbBSULvnPD3X/mg==} '@fastify/deepmerge@3.2.1': resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==} @@ -261,8 +270,8 @@ packages: '@fastify/fast-json-stringify-compiler@5.1.0': resolution: {integrity: sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==} - '@fastify/formbody@9.0.0': - resolution: {integrity: sha512-T/af26CSrUARBCvsEmv+DJLPfZlrRKESzqironxP1j7qzuLyKcoZtj6MuTGShuKx1THXugoie2oFbUJxXfGFzA==} + '@fastify/formbody@9.0.1': + resolution: {integrity: sha512-QaUet4NQavwnl6iPYMaf5hJC2N7kRPOmC58RhDiFThfg7Hcc8X2LdpcEfmaOEBimq5z7yZLy1e1q+PIv3o3tFQ==} '@fastify/forwarded@3.0.2': resolution: {integrity: sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==} @@ -282,11 +291,14 @@ packages: '@fastify/static@10.1.5': resolution: {integrity: sha512-3r0ZFXo82nKlPzySOUYhwWVfPZStfiwjmyevkX7cJWL2yXmEzl0Rpxv+L6L1R4OxqvlDGqf3g8OdBzILKgqe6w==} - '@fastify/swagger-ui@6.1.1': - resolution: {integrity: sha512-RKCLSHASlzS2JZvHWn14NmEpHyl0yNosGvqzhUumm/LGPG6RWQBf4oscTFt83QDvc5O5Tol3Beup8inAl/k4EA==} + '@fastify/static@10.1.6': + resolution: {integrity: sha512-f3x9XisI+y6AIhIfA/+Q3SFsuXeqNNk5HQh5htgHsPxmMLb4pLFskV6eFaLSW3IbI4azrS5np+/tkzXNH99LyQ==} - '@fastify/swagger@9.9.0': - resolution: {integrity: sha512-d21Im1l3KoF2kmzJHOYL50Erjhj1qUUudQsW9hzwYTE7likjvMx7AQKpClyZbdhrqGXbl+fU4dI2S7BaLpFIvw==} + '@fastify/swagger-ui@6.1.2': + resolution: {integrity: sha512-rG5EvbuzzH38XYULa9OVrSg3XngXlHmPFbuU4MKdrzx/lGHCvfPITIy7twnECBf7Ws4t2ZvwMa5UUe0T2+V49w==} + + '@fastify/swagger@9.9.2': + resolution: {integrity: sha512-ayxItVgf/1axBvw33lqsA5ns02YMpT1r59G/HCJraRNWWBzvLgN+oiCBPhuzTiN67TvctlCYkG0+40WLKYuusQ==} '@hey-api/codegen-core@0.9.1': resolution: {integrity: sha512-s97jL1dgTMuiMHv2BZ1X4Tgd99Mf9GOvGdNqNcGwIMmnR+PgYNoraj4Zvp134MKsNCap/m7k0r0vKKnl56pj4w==} @@ -342,33 +354,6 @@ packages: '@types/node': optional: true - '@inquirer/editor@5.3.3': - resolution: {integrity: sha512-YsKkS2q63IiLtaDK/9nqzdComN97SDQrmKiyNggN+ceP4ty+Z6VwyTz3FpjeUWeW1Efss2xHFKCC9sx7hnrsxg==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/expand@5.1.5': - resolution: {integrity: sha512-uHuXLmXW+TtIfT/9vSBotypAkqn1n34Ul+CLGPos/xANyO4Ff5xZzkYhbKR4NEcfVK4a9mHQOpwVZzluSHFRGw==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/external-editor@3.0.5': - resolution: {integrity: sha512-f3QQJRIX5ZEneBHNUIuPjmbdzHnmRFJA8r2dkcb8q+OM5Uv5KtnuAttQumnrjcBVBM3mcTX1CkmtAkU58VRZxg==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - '@inquirer/figures@2.0.9': resolution: {integrity: sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} @@ -382,51 +367,6 @@ packages: '@types/node': optional: true - '@inquirer/number@4.2.3': - resolution: {integrity: sha512-6Yuwh1NGSbu1Lo4N1EWjXs1jKRntLg/ZCwhmeorEHde90v1XxAozdbd4Iu30eOQLW+6h1hp2O9ujNfLSbTPJnA==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/password@5.2.2': - resolution: {integrity: sha512-W9zYdyzogK+6110mqwaSJWCBu2yA5Q/OfnGSjjZB1bNpHlmUozXxTl0+QOZBNeVd6Qo81/qT75gW05gLAtITxw==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/prompts@8.7.2': - resolution: {integrity: sha512-QoRB4wFIjgH5iOhSjoIKMkTvSHDuV+O3OITlIqAYO0oK5x364GJILXiMBvlPiE+klg7Xx9tq5XVqQHcGUDYYPA==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/rawlist@5.3.5': - resolution: {integrity: sha512-1oHky1ONfCOwNrnkQGDE1oaSij/3fI6HFMSf2H/WsGO2lEyDX9My82iggITSy9ddSZ8yk8j9v41OI0fVoSIoaA==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/search@4.3.3': - resolution: {integrity: sha512-fyuIU1Nbpvwlikjg3gXwJFDI11+EFjqQ7P+iByfmivIKQ1vmaykNrD/vy5unHuUqUpsOsnvJ25//tPF7E/RBRA==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - '@inquirer/select@5.2.5': resolution: {integrity: sha512-9kc15hr8r/kI+3DO/xLog5nOzTz1jqsHXa6JBFzmQKhkoJ8Slda1I1L/uD8ZSZ9tF1yp79wwXe7mclvX1rqR2Q==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} @@ -449,6 +389,16 @@ packages: resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@jsdevtools/ono@7.1.3': resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} @@ -456,45 +406,420 @@ packages: resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} engines: {node: '>=8'} + '@oxc-project/types@0.153.0': + resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==} + + '@oxfmt/binding-android-arm-eabi@0.73.0': + resolution: {integrity: sha512-dhunAdMbi/4o0Y66/+Wl6rpsNqpvE4i26Qw6bB77A97L2lJoktWg6m+X9QOon8iOEEqzD9rEZqFi5uZ4oIzhCA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxfmt/binding-android-arm64@0.73.0': + resolution: {integrity: sha512-dBoypjC2/xeyKyIhN2waX0/XeoccH9k0lVVe+sGRc4X8HSJVRtqyZDoXdr7bGY9A75c51dX0z0dLDpGQKwCdCg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxfmt/binding-darwin-arm64@0.73.0': + resolution: {integrity: sha512-Tn7Y+qWIpCQK+qdYwBcn6YvqeBuPiuG3tcNlYg16a5QL2o3rSmrgBB1nCyYqo0uvc1LNrfQW9/CK2tzFuID/Fg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxfmt/binding-darwin-x64@0.73.0': + resolution: {integrity: sha512-yVb0E0tg/+9Xc9YJCBq66qF1DC/aN5mhb5nnHz0k4ikwkMANtSul2VPDSwjYdXR6/DFimc+pPUgLBqkIa5Ctuw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxfmt/binding-freebsd-x64@0.73.0': + resolution: {integrity: sha512-IKR4lFnBkYNL9Uqdxa5GMTQLvnLj+3sDwTvh2atHCzah0xeo+1xR5bigRU8uyXlOsA4VpXUn0UAimq4E4RBGmQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxfmt/binding-linux-arm-gnueabihf@0.73.0': + resolution: {integrity: sha512-7qnZ4g0ZCO3aZevOOQpZIABYwwXJW1lLDo2elviGdPQ0i11255xKEMFsi97fC1/nauQFseCJfELUy/k2JoOP+g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm-musleabihf@0.73.0': + resolution: {integrity: sha512-36eBico2j/WPB84gb4FYd/jjqGCKOwU4M+TWLBUIqlnzTozQueLy0joA5BwsSp2OZFVkOlpCwvA3ndPamdiyLw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm64-gnu@0.73.0': + resolution: {integrity: sha512-VZ3cSgCAT18knKo52DcACmAuIoqG6qVEyeZRFbJd0xmYQ8GyYjuBc6IAh6MZHijT6q7p4/HPI+6ZWsCuhdV6iw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-arm64-musl@0.73.0': + resolution: {integrity: sha512-8YVzH59XSHcqwXNgc5qK851zCqwpmBgOifARXEPPe5/xTsIbHceYoPZAyqfpYV0CfKmzSaEDBgYHFza4rhx+Zw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-ppc64-gnu@0.73.0': + resolution: {integrity: sha512-MqRJnmCys0rwOgpWyEidSA7v/bvOqfNDJhPkQoR9mPVYrvSmMaW2Xhdpl28cbTA+brOM1is62GjIvJplVlUVpw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-gnu@0.73.0': + resolution: {integrity: sha512-4t2F3SWJSK/O/kRRFnL0elJzn75fJGGu71gs1HtiWcS80SmJOpkHiwwLnTp9suGdns8VHEPxYS0GTcyVwHKWBA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-musl@0.73.0': + resolution: {integrity: sha512-D8b4I8I1kLdJQFwdFXNV+cwh3xWSiNKofjdGUk0nDpc0AHOds8T+poGbP9RWQMvgsvMb9OMgp/aV7ahBR37TbQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-s390x-gnu@0.73.0': + resolution: {integrity: sha512-QrC1S5VL1+N5rvorB15jH7CPvjVHA7GyUI5oVEd8Bd9m5605RDBQYTxDQ920DuylRoZOh2V7/xXpfCtFUrojUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-gnu@0.73.0': + resolution: {integrity: sha512-dFcUKQPltCEPa+YlkF3zqufn6nJPZdG73LpnjBvZFFsU3laCMssDXSYB99mVaj43FqdEgjpz5TO6gbYXcczd9g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-musl@0.73.0': + resolution: {integrity: sha512-LmQe+nV8e+eUwfPNCxDF+owbsg88toBiR/8rZRgVOeKQP3MaGefXZRvBoc1UG37iWEQ/9JlCXG9Mc4nf4N3agw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-openharmony-arm64@0.73.0': + resolution: {integrity: sha512-8weOr1OKknE0G2DbR6ejJUT9AOsoBU6cXlsetmbihHv1K+bK7qP61T6lJxY0k/aAO7GyOJiRGNlL+04r4eH8Xg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxfmt/binding-win32-arm64-msvc@0.73.0': + resolution: {integrity: sha512-mZV+QxXYgb8E18gzAFItb937VIsE4dqWdStrD3tRRC52UubOYEuU+e7NaIqUsGdB5YsJqNeuck3Ue/af2r9wyA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxfmt/binding-win32-ia32-msvc@0.73.0': + resolution: {integrity: sha512-QJPUsbgUb97ZPPMkFUq6VQq0fLhC4Np19KL9aB/qKVwXnpKE6MIiILXqzhKnQ/UbnEDjKJ7Xv1d6zH4bYPCUdQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxfmt/binding-win32-x64-msvc@0.73.0': + resolution: {integrity: sha512-cGcze2fu+ykIaFtkEvZlkZ3HvnCLk+WISthtq99zWTWn0x13t5L9TxvGdLZMUMvtfYR+ICkiCwKnguVubdcw0w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxlint/binding-android-arm-eabi@1.88.0': + resolution: {integrity: sha512-k6Fgkf86ubfeH4GvqYsRolfuVAp0pfYVZvifSlXNHPdMaWUOfxUrmLEeDD7hF6U2oJuhsVxhSWq86/xCJ+9MmA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.88.0': + resolution: {integrity: sha512-AsIyhl9o1ejwtac7NMbhKpMdSml6NWR0ljKjZERxC8OHYDAW8o/Om9n1QHVLi4VCEUo42Zte2/Op9kyoGpJZMg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.88.0': + resolution: {integrity: sha512-qgBjB7eEKcCB4RqjXGuoSZq74g0wD09fWFJ+HWcCJcKaga8TeUHtb+BY0SsxdleSLBgx4RZ7qmgo0EzqEq7JoQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.88.0': + resolution: {integrity: sha512-sCz3X5Mw/xdzEzn4fsCt1Iq8NKJnGNfhR+b9B5k+4DysYAHsmIhYoocnBPiQ9kvY3+pUun2A0Z8Vtun/Nv6YoQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.88.0': + resolution: {integrity: sha512-xTOdYOIqCufgIj0knvxu86FBfovP6QBiD7ngNva01gb8xPQCD5gXkE5APGczH35zStim1O3zqvFbLQmY85xXRg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.88.0': + resolution: {integrity: sha512-BLadtw88NdYJBrAtC1dYzrPfSP2d5zgx6iuZycdD0YmZBJ2Nqbit9Z4PSaIb6gxK25dweArGMPbOmq/0ugwUPg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.88.0': + resolution: {integrity: sha512-wILSczl/DaZsCcf7j7tvNbssp8hxEHIJxrv4j7+JjI3TAL1U/JTWbeUtoJJC/vo8+2hkTmHLYoKJjmiNbijdXw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.88.0': + resolution: {integrity: sha512-jutT2Hy8JCuZuMwIFHo2HtyxfOfBJHyQxlBHBK1SL9W2f8n/WhjAP0FnDMz9d/mjhv9+QA0mx5AdQ9fDjsrFFg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-arm64-musl@1.88.0': + resolution: {integrity: sha512-8N8iGV9hh4Lkeuq7QsEkjoXNRRflqR1G2qyoGYXcA0k5qllrdqmfylKEXyHIVERxqNDyMO4StqDQ0uLxXcBQAg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-ppc64-gnu@1.88.0': + resolution: {integrity: sha512-5ydcQt3znNQ/rGEWYRvpS2cK9+T18P2VYgnyJ+eloEAwi9YUmsXy5VGrRqNWIK6qzWwHGnEE3ojVqqQNo+f0hA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-gnu@1.88.0': + resolution: {integrity: sha512-iGx0uvFT54b/5DPJdEBCxYIrVB3v0uTy+JhRh3CyEp8/22BXDkG8rffkFwREdLsdj9mOrPbJYVOZw8ipcBuzjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-musl@1.88.0': + resolution: {integrity: sha512-Wt47mKMWelVjRjuR7BCFxXkB0m79K1sTEbqMHRWciKUgo5iv7sWsF4RXSOyFYHD9zerNrkRZblnN273rhwOA1Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-s390x-gnu@1.88.0': + resolution: {integrity: sha512-g3L12KCS4+1gtUt3RbJ4F+Yxx4LNmmZLEPfhd8rAHEJjJk2lAgNfnjGoP/phJyhG5WS6VbNYbJtadJelNfvTMA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-gnu@1.88.0': + resolution: {integrity: sha512-og/XHVDOD9ZJgRiZkT9DgzQd4sr+JZW7e4l17Q/jVMZiIbeEd4uIu9iAoUbTF1gIvVZoRc4QDOxyVUvQGp4WHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-musl@1.88.0': + resolution: {integrity: sha512-EmhBsp+xuKb6YbZNEMLBo80Xw71AZOIm18do8ni/GLX/Lw1XzNhVYpbxO1KasYtoAvrGtCOdCPULRlIE0bPbTQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxlint/binding-openharmony-arm64@1.88.0': + resolution: {integrity: sha512-DBUcx/KwYdg1DAz+G01P/GFv512J04EsZadbHptx2FTpWT/MWRg21ZToNHt9pnsfQRBQ3et/g+vqzbvrdAVbuA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.88.0': + resolution: {integrity: sha512-xeVd0n4XG5HvynxfYvXi6uB+JvZbVZataowPS8cYpMbYg9mwE4sSyElFQd4LVJFifq8bRLDSR18XgEildmcXAg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.88.0': + resolution: {integrity: sha512-MA6iOw//amleaz2mVMsZrA7NHlEexjTrhIxSMFCXQi6IUuQO+3Od00GZhr2iCYw6RFzMW36jkKK/WHBXMsoQAw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.88.0': + resolution: {integrity: sha512-3Sz6rVWe8Dy+ufsXurFN1GX2dX43F/lQMSLelK9gFjiba7nHvLlqlhR5cHlWVnCpSqUY1hN9pyCX9XToe6ve6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} - '@scalar/helpers@0.11.1': - resolution: {integrity: sha512-Knwbe0IYqFk0PPDoOKLasqglBHfyf9/zwWWqFsSNi/AtdjM29wSZXN6p8DFid6iB5B9epYH9YiSgJ6tpD00TEw==} + '@rolldown/binding-android-arm-eabi@1.2.13': + resolution: {integrity: sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.13': + resolution: {integrity: sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.13': + resolution: {integrity: sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.13': + resolution: {integrity: sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.13': + resolution: {integrity: sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.13': + resolution: {integrity: sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.13': + resolution: {integrity: sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.13': + resolution: {integrity: sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.13': + resolution: {integrity: sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.13': + resolution: {integrity: sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.13': + resolution: {integrity: sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.13': + resolution: {integrity: sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.13': + resolution: {integrity: sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.13': + resolution: {integrity: sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.13': + resolution: {integrity: sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@scalar/helpers@0.11.3': + resolution: {integrity: sha512-4zPzuNTXObDUtZS93xzAoK83ddHDgBGifv/vKFe6bHqEl5i+IMLTTtEHOaaOZK4dusPEXcXsU5TBSaY/I6Mi+A==} engines: {node: '>=22'} '@scalar/helpers@0.12.0': resolution: {integrity: sha512-rcX0rFLiWWc0VBr/E+HkatubL0I0ZzxASQkd3032NWEMAEfNd2TiJ9kH46HwbSAOOl696ghxymWegjpjjvxwzA==} engines: {node: '>=22'} - '@scalar/json-magic@0.13.2': - resolution: {integrity: sha512-T8rQw5u7+MSTDpUcd5ShX1taOUxpZMv2b/P6xsahdlv/u68VX/Bq/+uzuAf2xW8IIOy7BEP4MBggle/vMDgAXw==} + '@scalar/helpers@0.16.3': + resolution: {integrity: sha512-He0CySU4Iv4DwyVF40EoVpvPneRQ1zqC/g1eBfR2i2G9RE/BEq/F5AiUrjIkbtTPjSbIiIL1AXMkXvBvgHVdsA==} engines: {node: '>=22'} '@scalar/json-magic@0.13.5': resolution: {integrity: sha512-w5eENfKAkc4CYiLva3nGUhTaJfyi3szmZ3E7+EHlzahOUnbDmB9rAWyzt8GFCXbwxeTuR+1m7lO1PyV/QAid4A==} engines: {node: '>=22'} - '@scalar/openapi-parser@0.28.16': - resolution: {integrity: sha512-zHEQExXo58Nk7/Tju4y4HLVJ+B5nnDmR7vPhhHLVinL0z99aGrd6S/8HREupVpfAsim326pfzIbSM+UOvEbnOw==} + '@scalar/json-magic@0.15.7': + resolution: {integrity: sha512-Vnu9wFHzuqpsx1DR6ePTsXgcBFnpRTEBH8HZAS1Nj7h7HfOsxaJtV5ig7pdLlLTK9bcJI+aZLndDZud5gysKFA==} + engines: {node: '>=22'} + + '@scalar/json-schema-validator@0.1.12': + resolution: {integrity: sha512-DjRUAFiMpq+ME1pD/xCUs317I1ve7ZKz2jLJ3ff/KY9tqlBOyEmY67ns3aQs+erBDPV/v68j8d9vkZPvy5iKEQ==} engines: {node: '>=22'} - '@scalar/openapi-types@0.9.5': - resolution: {integrity: sha512-czrz/zkVm1oPzrpYo3hI/iymfiw1s4dgJiQtwNi2U77Sqf3EQOGKsif4VNRa5suWMYRuPaFx5EiFM1FNEV4Whg==} + '@scalar/openapi-parser@0.29.13': + resolution: {integrity: sha512-k/4nft8xKVSP5pk7n9G8QxQ4wc+UwmtrAMQ8eCPKh5r2v8lvS3AXv2bHiBowMBnJnhua5hyomFzaaGs0LmB9yg==} engines: {node: '>=22'} '@scalar/openapi-types@0.9.7': resolution: {integrity: sha512-UVSgxRxcTaIbTZGtNRdQJHZC60uyYi29zfDqQVp0lxx07BNczIHbf9jycgI/+W3Uxxod+yUUt7hzxnKllOHE0w==} engines: {node: '>=22'} - '@scalar/openapi-upgrader@0.2.15': - resolution: {integrity: sha512-yqROK9U96ElasEL4Wl/+PIjQZlqrQXVUUpXk9PA6xBnrp8KdEv7at8pR5gsZkvddJfYVwLILPlctyqUg4u4YZA==} + '@scalar/openapi-types@0.9.8': + resolution: {integrity: sha512-pGcpkZObsGBC7nRKYte+6gstoe0C4hp7B2UWQYjg5To1wE68s3OHVvin+muL3tn3ZAoahXeJAkn1+aEcMi802w==} + engines: {node: '>=22'} + + '@scalar/openapi-upgrader@0.4.4': + resolution: {integrity: sha512-Agio5QlKUsJfMVe6nwkxjoQxI2o1lzBJYbPeHg+RKt5uAzU/zLI+h4xxPgwNaY2B9Ji5ekHGWmVkwsPnRBXl8g==} + engines: {node: '>=22'} + + '@scalar/openapi-validator@0.1.12': + resolution: {integrity: sha512-ZQ08rgGeQO+ikgASM09r0qRqg3H0qlGeeSKYz0mCf2TLwNGveQSGmWuMwF0hafNMmjvtsakob4XaMdWwhUpaxQ==} + engines: {node: '>=22'} + + '@scalar/types@0.19.0': + resolution: {integrity: sha512-EKeoWgUlP+uepbM/zEHKbsdpBNyOmSrw6DdU/WC0p53gz5uRzI7d/IEecIP9SQMUkResPR9DmWeWfFQftG7vqg==} + engines: {node: '>=22'} + + '@scalar/types@0.23.2': + resolution: {integrity: sha512-sya+RIpIODwcvem7SYRnPB0OdVZtQ15mSpVdPwB5qltYry7xgyp9skdc3PuxTDbyEmLuZ4ZmkSVybpaFMICZiQ==} engines: {node: '>=22'} '@seriousme/openapi-schema-validator@2.11.0': resolution: {integrity: sha512-DL8TjmjkUS76OaK/TinoRtf6PrirsWfMZiv62ifP9eq3UOpoLqSGZKpCErxMzz0i1pjECSPYY+qZ1SRYEgiEvg==} hasBin: true + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -622,40 +947,40 @@ packages: resolution: {integrity: sha512-mbCddXd+jm7hfx7w2YU64/Av4/NqqeG3GoRZgxPcgoTxYjhrcfJRw9ULch71SS4G+Q3bOXFhRvPqjguN0Hyp5w==} hasBin: true - '@typespec/asset-emitter@0.79.2': - resolution: {integrity: sha512-hcr0jxYgXM0suBwPtjnScYXZVIIqkcdd8jgYh1T7VLHem8+q0WthFQxeKbDIrip4k/nCFtnZui6Dj5oOVJb8fA==} + '@typespec/asset-emitter@0.79.3': + resolution: {integrity: sha512-mqEcgz4wSLe+Y8epvOTJnoOar/ePLSA/xirIaCSPVUvdbZo+fKvPfnKNiPvwtSuhEYlDPewva3PLTPtOTe6GXA==} engines: {node: '>=22.0.0'} peerDependencies: - '@typespec/compiler': ^1.16.0 + '@typespec/compiler': ^1.17.0 - '@typespec/compiler@1.16.0': - resolution: {integrity: sha512-pT4gN+OSEXZMLxIhtI3QgIUafeFktfIBKVb7p+X/rAzDymQSO8yAb+srMBQ43PIcmfrvMxCNaD1SKBa9AGhWtA==} + '@typespec/compiler@1.17.0': + resolution: {integrity: sha512-Prj9o2bjoWNS9wJ6sUk532jGm8h2AtpQ/lCFH6dhcywiP7UTj9cJtNlDAHptAS9Irh3YJM2g5D4/7S/u/vcG8Q==} engines: {node: '>=22.0.0'} hasBin: true - '@typespec/http@1.16.0': - resolution: {integrity: sha512-4cODL4uqoj6YGj3Z04mQx1JwrM7o4Ytv4dO75o3Y3fx66v4BF4B5hF2dwnwh02Xe0e9qO1aDoaRlO2QGDz3mEA==} + '@typespec/http@1.17.0': + resolution: {integrity: sha512-XLwL+NvkQ4hWhnIwZms2CNoxCULFt/jMa+QGaKg4hDwPFLvrXsUm7JWPHi1QOcwzGFG8Z1NglqRPzKl2der2ww==} engines: {node: '>=22.0.0'} peerDependencies: - '@typespec/compiler': ^1.16.0 - '@typespec/streams': ^0.86.0 + '@typespec/compiler': ^1.17.0 + '@typespec/streams': ^0.87.0 peerDependenciesMeta: '@typespec/streams': optional: true - '@typespec/openapi3@1.16.0': - resolution: {integrity: sha512-eRyTwn4w5hWSRm15ys20O2Jp1aHDPHp6TZsctOsG3nxo+N50Qg7Epgu44ATsxaELmnCEg9G0B7N9czZKb51dSQ==} + '@typespec/openapi3@1.17.0': + resolution: {integrity: sha512-BC5zSemNfyFWIgJOj0TrwVA3KULIvCmlrDhqESMbzmN4h3byhXR/PENxPP4TlPKfu3M1KE44esnxSDQ5Py3WnA==} engines: {node: '>=22.0.0'} hasBin: true peerDependencies: - '@typespec/compiler': ^1.16.0 - '@typespec/events': ^0.86.0 - '@typespec/http': ^1.16.0 - '@typespec/json-schema': ^1.16.0 - '@typespec/openapi': ^1.16.0 - '@typespec/sse': ^0.86.0 - '@typespec/streams': ^0.86.0 - '@typespec/versioning': ^0.86.0 + '@typespec/compiler': ^1.17.0 + '@typespec/events': ^0.87.0 + '@typespec/http': ^1.17.0 + '@typespec/json-schema': ^1.17.0 + '@typespec/openapi': ^1.17.0 + '@typespec/sse': ^0.87.0 + '@typespec/streams': ^0.87.0 + '@typespec/versioning': ^0.87.0 '@typespec/xml': '*' peerDependenciesMeta: '@typespec/events': @@ -671,25 +996,39 @@ packages: '@typespec/xml': optional: true - '@typespec/openapi@1.16.0': - resolution: {integrity: sha512-6F+3e1dOJkIV4K0vCsuDpdwxoXnsT6eOomd4MpIUJAwyA7etEraUmPM9Qstp42MLIBoPWm9dK4Y9Whxtohc/KA==} + '@typespec/openapi@1.17.0': + resolution: {integrity: sha512-0ScXsaovyFZWJv0nnokGI25vK5nRvJ8KfxiKokyXyQrwzK51eb61P/yZr/cZDGjk/w/qKHz2Vz1OvVC4ztdQdA==} engines: {node: '>=22.0.0'} peerDependencies: - '@typespec/compiler': ^1.16.0 - '@typespec/http': ^1.16.0 + '@typespec/compiler': ^1.17.0 + '@typespec/http': ^1.17.0 - '@typespec/rest@0.86.0': - resolution: {integrity: sha512-Lbl4x/ZW26r4ZsxpgvS7T3F998J7pvyDs+OsHgVKDI45EvPmdDydI4TKOH++UmHJ2tH8kw8KHzMPqCehWLIa6g==} + '@typespec/rest@0.87.0': + resolution: {integrity: sha512-Oi47F6IJWyP/efMXf+OUwJTKRrfPpsXPzFZIxG5x2HpAgTwQ0xAUPkrQ6XoQ4rdmwN3XPm0nV54lw5GKljB9LQ==} engines: {node: '>=22.0.0'} peerDependencies: - '@typespec/compiler': ^1.16.0 - '@typespec/http': ^1.16.0 + '@typespec/compiler': ^1.17.0 + '@typespec/http': ^1.17.0 - '@typespec/versioning@0.86.0': - resolution: {integrity: sha512-/VUgQXSNcwbhNllTKptGAcoxYCh3+E/a9RUcmlBj7v04yIN4rkSFFmxHKRWKuKaz9LYQerXSPrVNM6Ja1msFlw==} + '@typespec/versioning@0.87.0': + resolution: {integrity: sha512-GPL92BH29XAh10o0stg1LfW6RZyyQnlt8LAQ1i1Bfgnf6W0S32nCwbtVgHy8WX1u/g4sXWq74O3xi6Ng5+i68w==} engines: {node: '>=22.0.0'} peerDependencies: - '@typespec/compiler': ^1.16.0 + '@typespec/compiler': ^1.17.0 + + '@vitest/mocker@5.0.4': + resolution: {integrity: sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ==} + peerDependencies: + msw: ^2.4.9 || ^3.0.0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.0.4': + resolution: {integrity: sha512-MZKk/gSkm2q+ICobMxCpH1mA+cCAWm8WV7UhxlqGjq/sJ3AGc1BqFOs5CRVXxH78wQtme9q/NAAgBquj4AcyMw==} abstract-logging@2.0.1: resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} @@ -728,6 +1067,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} @@ -755,6 +1098,10 @@ packages: magicast: optional: true + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} + engines: {node: '>=18'} + chalk@6.0.1: resolution: {integrity: sha512-/Ce6KNm3vIbWdMlNna6RVIZ/ICQxnJxCicet5LBKK9ZffBkqzDw0xh9EiKSljdRtiIQ1S1z4YgcscUUGzNCWrA==} engines: {node: '>=22'} @@ -762,9 +1109,6 @@ packages: change-case@5.4.4: resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} - chardet@2.2.0: - resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} - chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} @@ -858,6 +1202,10 @@ packages: destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + dotenv@17.4.2: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} @@ -872,6 +1220,9 @@ packages: resolution: {integrity: sha512-pxP8eL2SwwaTRi/KHYwLYXinDs7gL3jxFcBYmEdYfZmZXbaVDvdppd0XBU8qVz03rDfKZMXg1omHCbsJjZrMsw==} engines: {node: '>=20'} + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -879,6 +1230,13 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + exsolve@1.1.1: resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==} @@ -918,8 +1276,8 @@ packages: fastify-allow@2.1.1: resolution: {integrity: sha512-op2GU5u5dmU3HTkQDm+lGCTJ6O08b7AikE9qqPvisxUDMT9zTqQymvy3HRSK35onvrlWm0rtBn2rSrR9p8835A==} - fastify-cli@8.0.2: - resolution: {integrity: sha512-LawMEiYoxlRf38gCEMlBojFZfDIVCT1NQ/z0SgQluJK5p14UBstl2R6OZVIxSFiFZsJC16rkACrLTJhq1YbaHQ==} + fastify-cli@8.0.3: + resolution: {integrity: sha512-PDYByiRitKMUAMUcch3wqm2T7FddYy5XiOQcJqBoZTS1H+IoBOMK+5uh8/iBRGO70KPVyY1TVXchdEj+9sTMgA==} hasBin: true fastify-openapi-glue@4.11.5: @@ -933,12 +1291,27 @@ packages: fastify-plugin@6.0.0: resolution: {integrity: sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==} + fastify-plugin@6.0.1: + resolution: {integrity: sha512-4wW4RxSmvPXLrh5mgQEiu43qU3bw44N2ss4FTkTkQIbprUhE+/s43QX0j/ttcztdWK7PEkHqVthdc5GdEGDvjA==} + fastify@5.12.5: resolution: {integrity: sha512-OB2k1dlxs5/NAABqeKV2FUHkSD2BbENsCak8yULVcymn3fHIPDVa9TI3SDnJSWYSllZmSYuZXy2gTnsT+Sut1A==} + fastify@5.12.7: + resolution: {integrity: sha512-v4pEPsTRPuqlDIYiQFB+BHjVN4SdHX9u0ABk0ZDUKbbdY1vsNryUd1j4Z+jQEZajvYIVk047Kimva9/AJ79GYw==} + fastq@1.20.3: resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + find-my-way@9.9.0: resolution: {integrity: sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==} engines: {node: '>=20'} @@ -947,6 +1320,11 @@ packages: resolution: {integrity: sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + generify@4.2.0: resolution: {integrity: sha512-b4cVhbPfbgbCZtK0dcUc1lASitXGEAIqukV5DDAyWm25fomWnV+C+a1yXvqikcRZXHN2j0pSDyj3cTfzq8pC7Q==} hasBin: true @@ -977,10 +1355,6 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} - iconv-lite@0.7.3: - resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} - engines: {node: '>=0.10.0'} - inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -1034,8 +1408,8 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} js-yaml@4.2.0: resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} @@ -1051,17 +1425,83 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - jsonpointer@5.0.1: - resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} - engines: {node: '>=0.10.0'} - - leven@4.1.0: - resolution: {integrity: sha512-KZ9W9nWDT7rF7Dazg8xyLHGLrmpgq2nVNFUckhqdW3szVP6YhCpp/RAnpmVExA9JvrMynjwSLVrEj3AepHR6ew==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - light-my-request@6.6.0: resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + locate-path@7.2.0: resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -1070,6 +1510,9 @@ packages: resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} engines: {node: 20 || >=22} + magic-string@1.4.3: + resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} + makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} @@ -1104,11 +1547,25 @@ packages: resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} engines: {node: ^20.17.0 || >=22.9.0} - npm-check-updates@23.1.0: - resolution: {integrity: sha512-u6Odx/JDpDXNqXp5pPsVN0RhuWkNrBe4Sz26J7yWJcEadopKhQgcB+bFq2PK35iJY6vc6h1PbnXmnwfiOFQl/w==} + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nanoid@5.1.16: + resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==} + engines: {node: ^18 || >=20} + hasBin: true + + npm-check-updates@23.1.1: + resolution: {integrity: sha512-UzYDvvsQRkmXR59g9SqsQxygYp7AhFpfEcOOGOJaB/P7pF/XlaqIYLK3UjUGfMHH1+aS2nZvJRaIsk2nk0z5ag==} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0, npm: '>=10.0.0'} hasBin: true + obug@3.0.0: + resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} + engines: {node: '>=12.20.0'} + ohash@2.0.12: resolution: {integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==} @@ -1126,6 +1583,35 @@ packages: openapi-types@12.1.3: resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + oxfmt@0.73.0: + resolution: {integrity: sha512-Ke4xbpuzqIBP4bkd48q815RhqWNJqOBzxVA9199M4qcF0y0LtpUv7UTg1dJ5ea6jw0RBUDZvX3qOUMNmmxfxbw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@astrojs/compiler-rs': ^0.4.0 || ^0.5.0 + svelte: ^5.0.0 + vite-plus: '*' + peerDependenciesMeta: + '@astrojs/compiler-rs': + optional: true + svelte: + optional: true + vite-plus: + optional: true + + oxlint@1.88.0: + resolution: {integrity: sha512-uIcFgw8BmqvFRGzH5+r7d2zJMEexwNSrEmM6WQNnGh3VkO0XYTEFoyxyNE6tjKMUib9Cue4XJA2DoWruO5D6aw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=7.0.2003' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + p-limit@4.0.0: resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -1155,6 +1641,10 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + pino-abstract-transport@3.0.0: resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} @@ -1177,6 +1667,10 @@ packages: engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} deprecated: Renamed to package-up + postcss@8.5.30: + resolution: {integrity: sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==} + engines: {node: ^10 || ^12 || >=14} + powershell-utils@0.1.0: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} @@ -1238,6 +1732,11 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rolldown@1.2.13: + resolution: {integrity: sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + run-applescript@7.1.0: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} @@ -1253,9 +1752,6 @@ packages: resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} engines: {node: '>=10'} - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - secure-json-parse@4.1.0: resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} @@ -1290,6 +1786,10 @@ packages: sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + split2@3.2.2: resolution: {integrity: sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==} @@ -1301,6 +1801,9 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + string-width@7.2.0: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} @@ -1320,6 +1823,10 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + tar@7.5.22: resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} engines: {node: '>=18'} @@ -1337,6 +1844,22 @@ packages: resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} engines: {node: '>=20'} + tinybench@6.2.2: + resolution: {integrity: sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==} + engines: {node: '>=20.0.0'} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@2.2.0: + resolution: {integrity: sha512-jBrmx4lYmaC9k/mgPbylxs7kBUxHtD8256up+HjLaDFfXScKJQyil+SWXSvhAtT7XHo+yTVlpB81PGmHP8oLSQ==} + engines: {node: ^20.0.0 || >=22.0.0} + tmpl@1.0.5: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} @@ -1348,6 +1871,10 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + type-fest@5.10.0: + resolution: {integrity: sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==} + engines: {node: '>=20'} + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} @@ -1362,9 +1889,97 @@ packages: resolution: {integrity: sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==} engines: {node: '>=20.18.1'} + undici@7.29.1: + resolution: {integrity: sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==} + engines: {node: '>=20.18.1'} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vite@8.3.4: + resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@5.0.4: + resolution: {integrity: sha512-z0WxZRV2Sg48wF0FSgMq3TFG8JS38/Qr96vZVZdX6+Em9raQTvnXQvdTFJe2m3rfyJnEHeHQ/snceIdi8D1pVg==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.4 + '@vitest/browser-preview': 5.0.4 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.4 + '@vitest/coverage-v8': 5.0.4 + '@vitest/ui': 5.0.4 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vscode-jsonrpc@9.0.3: resolution: {integrity: sha512-qETYf9PCl1lVfwR+oBmMfc4vP2nqLNA35ABxorw3j06+xlKRgDVxbLgKm4Y/xPL0YWdeDjLoI4ImICIT3nB0zQ==} engines: {node: '>=14.0.0'} @@ -1390,6 +2005,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@3.2.1: + resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} + engines: {node: '>=20.11'} + hasBin: true + wrap-ansi@9.0.2: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} @@ -1426,15 +2046,17 @@ packages: resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} engines: {node: '>=12.20'} + zod@4.6.7: + resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} + snapshots: - '@babel/code-frame@7.29.7': + '@babel/code-frame@8.0.6': dependencies: - '@babel/helper-validator-identifier': 7.29.7 - js-tokens: 4.0.0 - picocolors: 1.1.1 + '@babel/helper-validator-identifier': 8.0.6 + js-tokens: 10.0.0 - '@babel/helper-validator-identifier@7.29.7': {} + '@babel/helper-validator-identifier@8.0.6': {} '@fastify/accept-negotiator@2.1.0': {} @@ -1446,7 +2068,7 @@ snapshots: '@fastify/busboy@3.2.3': {} - '@fastify/cookie@11.1.2': + '@fastify/cookie@11.1.3': dependencies: cookie: 2.0.1 fastify-plugin: 6.0.0 @@ -1459,7 +2081,7 @@ snapshots: dependencies: fast-json-stringify: 7.0.1 - '@fastify/formbody@9.0.0': + '@fastify/formbody@9.0.1': dependencies: fast-querystring: 1.1.2 fastify-plugin: 6.0.0 @@ -1475,7 +2097,7 @@ snapshots: '@fastify/busboy': 3.2.3 '@fastify/deepmerge': 3.2.1 '@fastify/error': 4.2.0 - fastify-plugin: 6.0.0 + fastify-plugin: 6.0.1 secure-json-parse: 4.1.0 '@fastify/proxy-addr@5.1.1': @@ -1501,7 +2123,17 @@ snapshots: fastq: 1.20.3 glob: 13.0.6 - '@fastify/swagger-ui@6.1.1': + '@fastify/static@10.1.6': + dependencies: + '@fastify/accept-negotiator': 2.1.0 + '@fastify/error': 4.2.0 + '@fastify/send': 4.1.1 + content-disposition: 3.0.0 + fastify-plugin: 6.0.0 + fastq: 1.20.3 + glob: 13.0.6 + + '@fastify/swagger-ui@6.1.2': dependencies: '@fastify/static': 10.1.5 fastify-plugin: 6.0.0 @@ -1509,7 +2141,7 @@ snapshots: rfdc: 1.4.1 yaml: 2.9.1 - '@fastify/swagger@9.9.0': + '@fastify/swagger@9.9.2': dependencies: fastify-plugin: 6.0.0 json-schema-resolver: 3.0.0 @@ -1575,81 +2207,30 @@ snapshots: dependencies: '@inquirer/ansi': 2.0.8 '@inquirer/core': 12.0.3 - '@inquirer/figures': 2.0.9 - '@inquirer/type': 4.1.1 - - '@inquirer/confirm@6.3.2': - dependencies: - '@inquirer/core': 12.0.3 - '@inquirer/type': 4.1.1 - - '@inquirer/core@12.0.3': - dependencies: - '@inquirer/ansi': 2.0.8 - '@inquirer/figures': 2.0.9 - '@inquirer/type': 4.1.1 - cli-width: 4.1.0 - fast-wrap-ansi: 0.2.2 - mute-stream: 3.0.0 - signal-exit: 4.1.0 - - '@inquirer/editor@5.3.3': - dependencies: - '@inquirer/core': 12.0.3 - '@inquirer/external-editor': 3.0.5 - '@inquirer/type': 4.1.1 - - '@inquirer/expand@5.1.5': - dependencies: - '@inquirer/core': 12.0.3 - '@inquirer/type': 4.1.1 - - '@inquirer/external-editor@3.0.5': - dependencies: - chardet: 2.2.0 - iconv-lite: 0.7.3 - - '@inquirer/figures@2.0.9': {} - - '@inquirer/input@5.1.6': - dependencies: - '@inquirer/core': 12.0.3 - '@inquirer/type': 4.1.1 - - '@inquirer/number@4.2.3': - dependencies: - '@inquirer/core': 12.0.3 - '@inquirer/type': 4.1.1 - - '@inquirer/password@5.2.2': - dependencies: - '@inquirer/ansi': 2.0.8 - '@inquirer/core': 12.0.3 - '@inquirer/type': 4.1.1 - - '@inquirer/prompts@8.7.2': - dependencies: - '@inquirer/checkbox': 5.2.5 - '@inquirer/confirm': 6.3.2 - '@inquirer/editor': 5.3.3 - '@inquirer/expand': 5.1.5 - '@inquirer/input': 5.1.6 - '@inquirer/number': 4.2.3 - '@inquirer/password': 5.2.2 - '@inquirer/rawlist': 5.3.5 - '@inquirer/search': 4.3.3 - '@inquirer/select': 5.2.5 + '@inquirer/figures': 2.0.9 + '@inquirer/type': 4.1.1 - '@inquirer/rawlist@5.3.5': + '@inquirer/confirm@6.3.2': dependencies: '@inquirer/core': 12.0.3 '@inquirer/type': 4.1.1 - '@inquirer/search@4.3.3': + '@inquirer/core@12.0.3': dependencies: - '@inquirer/core': 12.0.3 + '@inquirer/ansi': 2.0.8 '@inquirer/figures': 2.0.9 '@inquirer/type': 4.1.1 + cli-width: 4.1.0 + fast-wrap-ansi: 0.2.2 + mute-stream: 3.0.0 + signal-exit: 4.1.0 + + '@inquirer/figures@2.0.9': {} + + '@inquirer/input@5.1.6': + dependencies: + '@inquirer/core': 12.0.3 + '@inquirer/type': 4.1.1 '@inquirer/select@5.2.5': dependencies: @@ -1664,21 +2245,189 @@ snapshots: dependencies: minipass: 7.1.3 + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + '@jsdevtools/ono@7.1.3': {} '@lukeed/ms@2.0.2': {} + '@oxc-project/types@0.153.0': {} + + '@oxfmt/binding-android-arm-eabi@0.73.0': + optional: true + + '@oxfmt/binding-android-arm64@0.73.0': + optional: true + + '@oxfmt/binding-darwin-arm64@0.73.0': + optional: true + + '@oxfmt/binding-darwin-x64@0.73.0': + optional: true + + '@oxfmt/binding-freebsd-x64@0.73.0': + optional: true + + '@oxfmt/binding-linux-arm-gnueabihf@0.73.0': + optional: true + + '@oxfmt/binding-linux-arm-musleabihf@0.73.0': + optional: true + + '@oxfmt/binding-linux-arm64-gnu@0.73.0': + optional: true + + '@oxfmt/binding-linux-arm64-musl@0.73.0': + optional: true + + '@oxfmt/binding-linux-ppc64-gnu@0.73.0': + optional: true + + '@oxfmt/binding-linux-riscv64-gnu@0.73.0': + optional: true + + '@oxfmt/binding-linux-riscv64-musl@0.73.0': + optional: true + + '@oxfmt/binding-linux-s390x-gnu@0.73.0': + optional: true + + '@oxfmt/binding-linux-x64-gnu@0.73.0': + optional: true + + '@oxfmt/binding-linux-x64-musl@0.73.0': + optional: true + + '@oxfmt/binding-openharmony-arm64@0.73.0': + optional: true + + '@oxfmt/binding-win32-arm64-msvc@0.73.0': + optional: true + + '@oxfmt/binding-win32-ia32-msvc@0.73.0': + optional: true + + '@oxfmt/binding-win32-x64-msvc@0.73.0': + optional: true + + '@oxlint/binding-android-arm-eabi@1.88.0': + optional: true + + '@oxlint/binding-android-arm64@1.88.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.88.0': + optional: true + + '@oxlint/binding-darwin-x64@1.88.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.88.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.88.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.88.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.88.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.88.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.88.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.88.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.88.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.88.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.88.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.88.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.88.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.88.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.88.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.88.0': + optional: true + '@pinojs/redact@0.4.0': {} - '@scalar/helpers@0.11.1': {} + '@rolldown/binding-android-arm-eabi@1.2.13': + optional: true + + '@rolldown/binding-android-arm64@1.2.13': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.13': + optional: true + + '@rolldown/binding-darwin-x64@1.2.13': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.13': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.13': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.13': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.13': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.13': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.13': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.13': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@scalar/helpers@0.11.3': {} '@scalar/helpers@0.12.0': {} - '@scalar/json-magic@0.13.2': - dependencies: - '@scalar/helpers': 0.11.1 - pathe: 2.0.3 - yaml: 2.9.1 + '@scalar/helpers@0.16.3': {} '@scalar/json-magic@0.13.5': dependencies: @@ -1687,26 +2436,61 @@ snapshots: undici: 7.24.4 yaml: 2.9.1 - '@scalar/openapi-parser@0.28.16': + '@scalar/json-magic@0.15.7': + dependencies: + '@scalar/helpers': 0.16.3 + pathe: 2.0.3 + undici: 7.29.1 + yaml: 2.9.1 + + '@scalar/json-schema-validator@0.1.12': dependencies: - '@scalar/helpers': 0.11.1 - '@scalar/json-magic': 0.13.2 - '@scalar/openapi-types': 0.9.5 - '@scalar/openapi-upgrader': 0.2.15 + '@scalar/helpers': 0.16.3 + '@scalar/types': 0.23.2 ajv: 8.20.0 ajv-draft-04: 1.0.0(ajv@8.20.0) ajv-formats: 3.0.1(ajv@8.20.0) - jsonpointer: 5.0.1 - leven: 4.1.0 yaml: 2.9.1 - '@scalar/openapi-types@0.9.5': {} + '@scalar/openapi-parser@0.29.13': + dependencies: + '@scalar/helpers': 0.16.3 + '@scalar/json-magic': 0.15.7 + '@scalar/openapi-types': 0.9.8 + '@scalar/openapi-upgrader': 0.4.4 + '@scalar/openapi-validator': 0.1.12 + '@scalar/types': 0.23.2 + yaml: 2.9.1 '@scalar/openapi-types@0.9.7': {} - '@scalar/openapi-upgrader@0.2.15': + '@scalar/openapi-types@0.9.8': {} + + '@scalar/openapi-upgrader@0.4.4': + dependencies: + '@scalar/helpers': 0.16.3 + '@scalar/openapi-types': 0.9.8 + + '@scalar/openapi-validator@0.1.12': + dependencies: + '@scalar/helpers': 0.16.3 + '@scalar/json-schema-validator': 0.1.12 + '@scalar/types': 0.23.2 + yaml: 2.9.1 + + '@scalar/types@0.19.0': dependencies: - '@scalar/openapi-types': 0.9.5 + '@scalar/helpers': 0.11.3 + nanoid: 5.1.16 + type-fest: 5.10.0 + zod: 4.6.7 + + '@scalar/types@0.23.2': + dependencies: + '@scalar/helpers': 0.16.3 + nanoid: 5.1.16 + type-fest: 5.10.0 + zod: 4.6.7 '@seriousme/openapi-schema-validator@2.11.0': dependencies: @@ -1715,6 +2499,15 @@ snapshots: ajv-formats: 3.0.1(ajv@8.20.0) yaml: 2.9.1 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + '@types/json-schema@7.0.15': {} '@typescript/typescript-aix-ppc64@7.0.2': @@ -1781,14 +2574,17 @@ snapshots: dependencies: '@typescript/old': typescript@6.0.3 - '@typespec/asset-emitter@0.79.2(@typespec/compiler@1.16.0)': + '@typespec/asset-emitter@0.79.3(@typespec/compiler@1.17.0)': dependencies: - '@typespec/compiler': 1.16.0 + '@typespec/compiler': 1.17.0 - '@typespec/compiler@1.16.0': + '@typespec/compiler@1.17.0': dependencies: - '@babel/code-frame': 7.29.7 - '@inquirer/prompts': 8.7.2 + '@babel/code-frame': 8.0.6 + '@inquirer/checkbox': 5.2.5 + '@inquirer/confirm': 6.3.2 + '@inquirer/input': 5.1.6 + '@inquirer/select': 5.2.5 ajv: 8.20.0 change-case: 5.4.4 env-paths: 4.0.0 @@ -1806,36 +2602,48 @@ snapshots: transitivePeerDependencies: - '@types/node' - '@typespec/http@1.16.0(@typespec/compiler@1.16.0)': + '@typespec/http@1.17.0(@typespec/compiler@1.17.0)': dependencies: - '@typespec/compiler': 1.16.0 + '@typespec/compiler': 1.17.0 - '@typespec/openapi3@1.16.0(@typespec/compiler@1.16.0)(@typespec/http@1.16.0(@typespec/compiler@1.16.0))(@typespec/openapi@1.16.0(@typespec/compiler@1.16.0)(@typespec/http@1.16.0(@typespec/compiler@1.16.0)))(@typespec/versioning@0.86.0(@typespec/compiler@1.16.0))': + '@typespec/openapi3@1.17.0(@typespec/compiler@1.17.0)(@typespec/http@1.17.0(@typespec/compiler@1.17.0))(@typespec/openapi@1.17.0(@typespec/compiler@1.17.0)(@typespec/http@1.17.0(@typespec/compiler@1.17.0)))(@typespec/versioning@0.87.0(@typespec/compiler@1.17.0))': dependencies: '@scalar/json-magic': 0.13.5 - '@scalar/openapi-parser': 0.28.16 + '@scalar/openapi-parser': 0.29.13 '@scalar/openapi-types': 0.9.7 - '@typespec/asset-emitter': 0.79.2(@typespec/compiler@1.16.0) - '@typespec/compiler': 1.16.0 - '@typespec/http': 1.16.0(@typespec/compiler@1.16.0) - '@typespec/openapi': 1.16.0(@typespec/compiler@1.16.0)(@typespec/http@1.16.0(@typespec/compiler@1.16.0)) + '@scalar/types': 0.19.0 + '@typespec/asset-emitter': 0.79.3(@typespec/compiler@1.17.0) + '@typespec/compiler': 1.17.0 + '@typespec/http': 1.17.0(@typespec/compiler@1.17.0) + '@typespec/openapi': 1.17.0(@typespec/compiler@1.17.0)(@typespec/http@1.17.0(@typespec/compiler@1.17.0)) yaml: 2.9.1 optionalDependencies: - '@typespec/versioning': 0.86.0(@typespec/compiler@1.16.0) + '@typespec/versioning': 0.87.0(@typespec/compiler@1.17.0) + + '@typespec/openapi@1.17.0(@typespec/compiler@1.17.0)(@typespec/http@1.17.0(@typespec/compiler@1.17.0))': + dependencies: + '@typespec/compiler': 1.17.0 + '@typespec/http': 1.17.0(@typespec/compiler@1.17.0) - '@typespec/openapi@1.16.0(@typespec/compiler@1.16.0)(@typespec/http@1.16.0(@typespec/compiler@1.16.0))': + '@typespec/rest@0.87.0(@typespec/compiler@1.17.0)(@typespec/http@1.17.0(@typespec/compiler@1.17.0))': dependencies: - '@typespec/compiler': 1.16.0 - '@typespec/http': 1.16.0(@typespec/compiler@1.16.0) + '@typespec/compiler': 1.17.0 + '@typespec/http': 1.17.0(@typespec/compiler@1.17.0) - '@typespec/rest@0.86.0(@typespec/compiler@1.16.0)(@typespec/http@1.16.0(@typespec/compiler@1.16.0))': + '@typespec/versioning@0.87.0(@typespec/compiler@1.17.0)': dependencies: - '@typespec/compiler': 1.16.0 - '@typespec/http': 1.16.0(@typespec/compiler@1.16.0) + '@typespec/compiler': 1.17.0 - '@typespec/versioning@0.86.0(@typespec/compiler@1.16.0)': + '@vitest/mocker@5.0.4(vite@8.3.4(jiti@2.7.0)(yaml@2.9.1))': dependencies: - '@typespec/compiler': 1.16.0 + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.4 + estree-walker: 3.0.3 + magic-string: 1.4.3 + optionalDependencies: + vite: 8.3.4(jiti@2.7.0)(yaml@2.9.1) + + '@vitest/spy@5.0.4': {} abstract-logging@2.0.1: {} @@ -1862,6 +2670,8 @@ snapshots: argparse@2.0.1: {} + assertion-error@2.0.1: {} + atomic-sleep@1.0.0: {} avvio@9.3.0: @@ -1894,12 +2704,12 @@ snapshots: pkg-types: 2.3.3 rc9: 3.1.0 + chai@6.3.0: {} + chalk@6.0.1: {} change-case@5.4.4: {} - chardet@2.2.0: {} - chokidar@5.0.0: dependencies: readdirp: 5.1.1 @@ -1963,6 +2773,8 @@ snapshots: destr@2.0.5: {} + detect-libc@2.1.2: {} + dotenv@17.4.2: {} emoji-regex@10.6.0: {} @@ -1975,10 +2787,18 @@ snapshots: dependencies: is-safe-filename: 0.1.1 + es-module-lexer@2.3.2: {} + escalade@3.2.0: {} escape-html@1.0.3: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + exsolve@1.1.1: {} fast-copy@4.1.1: {} @@ -2020,7 +2840,7 @@ snapshots: dependencies: fastify-plugin: 4.5.1 - fastify-cli@8.0.2: + fastify-cli@8.0.3: dependencies: '@fastify/deepmerge': 3.2.1 chalk: 6.0.1 @@ -2035,18 +2855,19 @@ snapshots: pino-pretty: 13.1.3 pkg-up: 5.0.0 resolve-from: 5.0.0 - semver: 7.8.5 fastify-openapi-glue@4.11.5: dependencies: '@seriousme/openapi-schema-validator': 2.11.0 - fastify-plugin: 6.0.0 + fastify-plugin: 6.0.1 yaml: 2.9.1 fastify-plugin@4.5.1: {} fastify-plugin@6.0.0: {} + fastify-plugin@6.0.1: {} + fastify@5.12.5: dependencies: '@fastify/ajv-compiler': 4.0.6 @@ -2065,10 +2886,32 @@ snapshots: semver: 7.8.5 toad-cache: 3.7.4 + fastify@5.12.7: + dependencies: + '@fastify/ajv-compiler': 4.0.6 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.1.0 + '@fastify/proxy-addr': 5.1.1 + abstract-logging: 2.0.1 + avvio: 9.3.0 + fast-json-stringify: 7.0.1 + find-my-way: 9.9.0 + light-my-request: 6.6.0 + pino: 10.3.1 + process-warning: 5.1.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.8.5 + toad-cache: 3.7.4 + fastq@1.20.3: dependencies: reusify: 1.1.0 + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + find-my-way@9.9.0: dependencies: fast-deep-equal: 3.1.3 @@ -2080,6 +2923,9 @@ snapshots: locate-path: 7.2.0 path-exists: 5.0.0 + fsevents@2.3.3: + optional: true + generify@4.2.0: dependencies: isbinaryfile: 4.0.10 @@ -2113,10 +2959,6 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - iconv-lite@0.7.3: - dependencies: - safer-buffer: 2.1.2 - inherits@2.0.4: {} ipaddr.js@2.5.0: {} @@ -2147,7 +2989,7 @@ snapshots: joycon@3.1.1: {} - js-tokens@4.0.0: {} + js-tokens@10.0.0: {} js-yaml@4.2.0: dependencies: @@ -2167,22 +3009,71 @@ snapshots: json-schema-traverse@1.0.0: {} - jsonpointer@5.0.1: {} - - leven@4.1.0: {} - light-my-request@6.6.0: dependencies: cookie: 1.1.1 process-warning: 4.0.1 set-cookie-parser: 2.7.2 + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + locate-path@7.2.0: dependencies: p-locate: 6.0.0 lru-cache@11.5.3: {} + magic-string@1.4.3: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + makeerror@1.0.12: dependencies: tmpl: 1.0.5 @@ -2207,7 +3098,13 @@ snapshots: mute-stream@3.0.0: {} - npm-check-updates@23.1.0: {} + nanoid@3.3.20: {} + + nanoid@5.1.16: {} + + npm-check-updates@23.1.1: {} + + obug@3.0.0: {} ohash@2.0.12: {} @@ -2228,6 +3125,52 @@ snapshots: openapi-types@12.1.3: {} + oxfmt@0.73.0: + dependencies: + tinypool: 2.2.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.73.0 + '@oxfmt/binding-android-arm64': 0.73.0 + '@oxfmt/binding-darwin-arm64': 0.73.0 + '@oxfmt/binding-darwin-x64': 0.73.0 + '@oxfmt/binding-freebsd-x64': 0.73.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.73.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.73.0 + '@oxfmt/binding-linux-arm64-gnu': 0.73.0 + '@oxfmt/binding-linux-arm64-musl': 0.73.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.73.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.73.0 + '@oxfmt/binding-linux-riscv64-musl': 0.73.0 + '@oxfmt/binding-linux-s390x-gnu': 0.73.0 + '@oxfmt/binding-linux-x64-gnu': 0.73.0 + '@oxfmt/binding-linux-x64-musl': 0.73.0 + '@oxfmt/binding-openharmony-arm64': 0.73.0 + '@oxfmt/binding-win32-arm64-msvc': 0.73.0 + '@oxfmt/binding-win32-ia32-msvc': 0.73.0 + '@oxfmt/binding-win32-x64-msvc': 0.73.0 + + oxlint@1.88.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.88.0 + '@oxlint/binding-android-arm64': 1.88.0 + '@oxlint/binding-darwin-arm64': 1.88.0 + '@oxlint/binding-darwin-x64': 1.88.0 + '@oxlint/binding-freebsd-x64': 1.88.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.88.0 + '@oxlint/binding-linux-arm-musleabihf': 1.88.0 + '@oxlint/binding-linux-arm64-gnu': 1.88.0 + '@oxlint/binding-linux-arm64-musl': 1.88.0 + '@oxlint/binding-linux-ppc64-gnu': 1.88.0 + '@oxlint/binding-linux-riscv64-gnu': 1.88.0 + '@oxlint/binding-linux-riscv64-musl': 1.88.0 + '@oxlint/binding-linux-s390x-gnu': 1.88.0 + '@oxlint/binding-linux-x64-gnu': 1.88.0 + '@oxlint/binding-linux-x64-musl': 1.88.0 + '@oxlint/binding-openharmony-arm64': 1.88.0 + '@oxlint/binding-win32-arm64-msvc': 1.88.0 + '@oxlint/binding-win32-ia32-msvc': 1.88.0 + '@oxlint/binding-win32-x64-msvc': 1.88.0 + p-limit@4.0.0: dependencies: yocto-queue: 1.2.2 @@ -2251,6 +3194,8 @@ snapshots: picocolors@1.1.1: {} + picomatch@4.0.7: {} + pino-abstract-transport@3.0.0: dependencies: split2: 4.2.0 @@ -2297,6 +3242,12 @@ snapshots: dependencies: find-up: 6.3.0 + postcss@8.5.30: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + powershell-utils@0.1.0: {} prettier@3.9.9: {} @@ -2341,6 +3292,27 @@ snapshots: rfdc@1.4.1: {} + rolldown@1.2.13: + dependencies: + '@oxc-project/types': 0.153.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.13 + '@rolldown/binding-android-arm64': 1.2.13 + '@rolldown/binding-darwin-arm64': 1.2.13 + '@rolldown/binding-darwin-x64': 1.2.13 + '@rolldown/binding-freebsd-x64': 1.2.13 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.13 + '@rolldown/binding-linux-arm64-gnu': 1.2.13 + '@rolldown/binding-linux-arm64-musl': 1.2.13 + '@rolldown/binding-linux-ppc64-gnu': 1.2.13 + '@rolldown/binding-linux-s390x-gnu': 1.2.13 + '@rolldown/binding-linux-x64-gnu': 1.2.13 + '@rolldown/binding-linux-x64-musl': 1.2.13 + '@rolldown/binding-openharmony-arm64': 1.2.13 + '@rolldown/binding-win32-arm64-msvc': 1.2.13 + '@rolldown/binding-win32-x64-msvc': 1.2.13 + run-applescript@7.1.0: {} safe-buffer@5.2.1: {} @@ -2351,8 +3323,6 @@ snapshots: safe-stable-stringify@2.5.0: {} - safer-buffer@2.1.2: {} - secure-json-parse@4.1.0: {} semver@7.8.4: {} @@ -2375,6 +3345,8 @@ snapshots: dependencies: atomic-sleep: 1.0.0 + source-map-js@1.2.2: {} + split2@3.2.2: dependencies: readable-stream: 3.6.2 @@ -2383,6 +3355,8 @@ snapshots: statuses@2.0.2: {} + std-env@4.3.0: {} + string-width@7.2.0: dependencies: emoji-regex: 10.6.0 @@ -2404,6 +3378,8 @@ snapshots: strip-json-comments@5.0.3: {} + tagged-tag@1.0.0: {} + tar@7.5.22: dependencies: '@isaacs/fs-minipass': 4.0.1 @@ -2425,12 +3401,27 @@ snapshots: dependencies: real-require: 1.0.0 + tinybench@6.2.2: {} + + tinyexec@1.3.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinypool@2.2.0: {} + tmpl@1.0.5: {} toad-cache@3.7.4: {} toidentifier@1.0.1: {} + type-fest@5.10.0: + dependencies: + tagged-tag: 1.0.0 + typescript@6.0.3: {} typescript@7.0.2: @@ -2458,8 +3449,41 @@ snapshots: undici@7.24.4: {} + undici@7.29.1: {} + util-deprecate@1.0.2: {} + vite@8.3.4(jiti@2.7.0)(yaml@2.9.1): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.30 + rolldown: 1.2.13 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + jiti: 2.7.0 + yaml: 2.9.1 + + vitest@5.0.4(vite@8.3.4(jiti@2.7.0)(yaml@2.9.1)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.4(vite@8.3.4(jiti@2.7.0)(yaml@2.9.1)) + chai: 6.3.0 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.3 + obug: 3.0.0 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 6.2.2 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: 8.3.4(jiti@2.7.0)(yaml@2.9.1) + why-is-node-running: 3.2.1 + transitivePeerDependencies: + - msw + vscode-jsonrpc@9.0.3: {} vscode-languageserver-protocol@3.18.4: @@ -2483,6 +3507,8 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@3.2.1: {} + wrap-ansi@9.0.2: dependencies: ansi-styles: 6.2.3 @@ -2514,3 +3540,5 @@ snapshots: yargs-parser: 22.0.0 yocto-queue@1.2.2: {} + + zod@4.6.7: {} From e320c60a8238276f703ab93579a6b29c8d14fc35 Mon Sep 17 00:00:00 2001 From: Kirill Mokevnin Date: Wed, 14 Oct 2026 16:17:38 -0400 Subject: [PATCH 2/4] =?UTF-8?q?test:=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BD?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B8=20smoke-=D0=BF=D1=80=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=D0=BD=20=D0=BD=D0=B0=20vitest=20=D1=81=20app.inject?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- Makefile | 2 +- bin/body-smoke-test.js | 73 ----- bin/smoke-test.js | 656 ------------------------------------- test/body-examples.test.ts | 89 +++++ test/collections.test.ts | 51 +++ test/helpers.ts | 64 ++++ test/http-api.test.ts | 268 +++++++++++++++ test/other-specs.test.ts | 131 ++++++++ test/spec-examples.test.ts | 38 +++ vitest.config.ts | 10 + 10 files changed, 652 insertions(+), 730 deletions(-) delete mode 100644 bin/body-smoke-test.js delete mode 100755 bin/smoke-test.js create mode 100644 test/body-examples.test.ts create mode 100644 test/collections.test.ts create mode 100644 test/helpers.ts create mode 100644 test/http-api.test.ts create mode 100644 test/other-specs.test.ts create mode 100644 test/spec-examples.test.ts create mode 100644 vitest.config.ts diff --git a/Makefile b/Makefile index 861b563..6fd14c0 100644 --- a/Makefile +++ b/Makefile @@ -26,7 +26,7 @@ start: test: make check-generated - node ./bin/smoke-test.js + pnpm exec vitest run update-deps: pnpm exec ncu -u diff --git a/bin/body-smoke-test.js b/bin/body-smoke-test.js deleted file mode 100644 index 9fe0d02..0000000 --- a/bin/body-smoke-test.js +++ /dev/null @@ -1,73 +0,0 @@ -export default async function checkBodyExamples({ APP, check, getJson }) { - console.log("\nФорматы тела учебных запросов"); - const fields = { title: "Check the automation task", description: "Review the test request" }; - const formPage = await fetch(`${APP}/http-protocol/form`); - const formHtml = await formPage.text(); - check( - "форма доступна и задаёт имена обоих полей", - formPage.status === 200 && - formHtml.includes('name="title"') && - formHtml.includes('name="description"'), - ); - const formReply = await fetch(`${APP}/http-protocol/form`, { - method: "POST", - body: new URLSearchParams(fields), - }); - check( - "браузерная форма возвращает прочитанные значения", - formReply.status === 200 && - (await formReply.text()) === `title=${fields.title}\ndescription=${fields.description}\n`, - ); - const jsonReply = await getJson(`${APP}/http-protocol/body`, { - method: "POST", - headers: { "Content-Type": "application/json", Accept: "application/json" }, - body: JSON.stringify({ ...fields, priority: 2, urgent: false }), - }); - check( - "JSON сохраняет типы и отражает заголовки", - jsonReply.status === 200 && - jsonReply.body.json.priority === 2 && - jsonReply.body.json.urgent === false && - jsonReply.body.headers.Accept === "application/json", - ); - const encodedReply = await getJson(`${APP}/http-protocol/body`, { - method: "POST", - body: new URLSearchParams(fields), - }); - check( - "кодированные поля декодируются с пробелами", - encodedReply.status === 200 && - encodedReply.body.form.title === fields.title && - encodedReply.body.json === null, - ); - const multipartBody = new FormData(); - multipartBody.set("title", fields.title); - multipartBody.set( - "attachment", - new Blob(["Please check the automation task\n"], { - type: "text/plain", - }), - "http-body-note.txt", - ); - const multipartReply = await getJson(`${APP}/http-protocol/body`, { - method: "POST", - body: multipartBody, - }); - check( - "multipart передаёт содержимое файла и текстовое поле", - multipartReply.status === 200 && - multipartReply.body.form.title === fields.title && - multipartReply.body.files.attachment === "Please check the automation task\n" && - multipartReply.body.headers["Content-Type"].includes("boundary="), - ); - const imageReply = await fetch(`${APP}/http-protocol/image.png`); - const imageData = new Uint8Array(await imageReply.arrayBuffer()); - check( - "PNG имеет заявленный размер и сигнатуру", - imageReply.status === 200 && - imageReply.headers.get("Content-Type").startsWith("image/png") && - Number(imageReply.headers.get("Content-Length")) === imageData.length && - imageData.length === 8090 && - imageData.slice(0, 8).join(",") === "137,80,78,71,13,10,26,10", - ); -} diff --git a/bin/smoke-test.js b/bin/smoke-test.js deleted file mode 100755 index afd6e76..0000000 --- a/bin/smoke-test.js +++ /dev/null @@ -1,656 +0,0 @@ -#!/usr/bin/env node - -// Дымовой прогон http-api: поднимает приложение и проверяет ответы запросами. -// Мока prism у этой спецификации больше нет, все её маршруты обслуживает -// приложение, см. custom-server/src/http-api.ts. -// -// Проверяется то, что уже ломалось незаметно. -// -// Первое: REST и RPC отдают одни и те же задачи. Урок kinds курса http-api -// сравнивает два стиля на одних данных. Оба обслуживаются модулем -// custom-server/src/tasks-store.js, а те же задачи продублированы примерами в -// спецификации, откуда их берёт документация курса. -// -// Второе: коды ответов. На 404, 405, 422, 401, 201 и 204 построены -// самостоятельные работы, причём урок kinds просит записать три *разных* кода на -// три неудачных запроса. Коды теперь дают спецификация (валидация и @useAuth), -// плагин fastify-allow (405) и обработчик ошибок (422 вместо 400), поэтому -// потерять их можно правкой любого из трёх мест. -// -// Третье: соответствие спецификации. Модели объявляют uint16, а динамический -// мок про это ограничение не знает и отдавал отрицательные id. -// -// Четвёртое: skip, limit и отбор по пути. Ровно то, чего не умел статичный мок -// и из-за чего уроки приходилось подгонять под сервер (FEEDBACK-371, #16). -// -// Caddy здесь не участвует: он есть только в образе, а в CI его нет. Приложение -// опрашивается напрямую по своему порту, с полными путями: Caddy префикс не -// срезает. - -import { spawn } from 'node:child_process'; -import { once } from 'node:events'; -import { readFileSync } from 'node:fs'; -import checkBodyExamples from './body-smoke-test.js'; - -import expectedComments from '../custom-server/src/data/comments.js'; -import expectedCourses from '../custom-server/src/data/courses.js'; -import expectedPosts from '../custom-server/src/data/posts.js'; -import expectedTasks from '../custom-server/src/data/tasks.js'; -import expectedUsers from '../custom-server/src/data/users.js'; - -const SPEC = './tsp-output/http-api/@typespec/openapi3/openapi.1.0.yaml'; -const APP = 'http://127.0.0.1:4010'; -const TASKS = `${APP}/http-api/tasks`; -const USERS = `${APP}/http-api/users`; -const POSTS = `${APP}/http-api/posts`; -const COMMENTS = `${APP}/http-api/comments`; -const COURSES = `${APP}/http-api/courses`; -const LOGIN = `${APP}/http-api/login`; -const KEY = { 'X-API-KEY': 'any-value' }; -const UINT16_MAX = 65535; - -const failures = []; -const children = []; - -const check = (name, passed, detail = '') => { - if (passed) { - console.log(` ok ${name}`); - return; - } - console.log(` FAIL ${name}${detail ? `\n ${detail}` : ''}`); - failures.push(name); -}; - -// Вывод дочерних процессов обязательно вычитывать. Если оставить трубу без -// читателя, prism упирается в заполненный буфер и встаёт, а прогон выглядит -// зависшим без всякой диагностики. Собранный вывод печатается, только если -// сервис не поднялся. -const start = (name, command, args) => { - // detached обязателен. pnpm это обёртка, она порождает настоящий процесс внуком, - // и SIGTERM самой обёртке внука не задевает: prism и fastify продолжают жить, - // держат наши трубы открытыми, и прогон не завершается даже после всех проверок. - // Поэтому каждый сервис заводится своей группой процессов и снимается целиком. - const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], detached: true }); - const output = []; - child.stdout.on('data', (chunk) => output.push(chunk.toString())); - child.stderr.on('data', (chunk) => output.push(chunk.toString())); - children.push({ name, child, output }); - return child; -}; - -const dumpOutput = () => { - for (const { name, output } of children) { - console.log(`\n--- вывод ${name} ---\n${output.join('').trim() || '(пусто)'}`); - } -}; - -const killGroup = (child, signal) => { - try { - process.kill(-child.pid, signal); - } catch { - // группы уже нет, значит процесс снят - } -}; - -const stopAll = async () => { - await Promise.all(children.map(async ({ child }) => { - if (child.exitCode !== null || child.signalCode !== null) return; - killGroup(child, 'SIGTERM'); - await Promise.race([once(child, 'exit'), new Promise((r) => setTimeout(r, 3000))]); - killGroup(child, 'SIGKILL'); - })); -}; - -const waitFor = async (url, name) => { - for (let i = 0; i < 60; i += 1) { - try { - await fetch(url); - return; - } catch { - await new Promise((resolve) => setTimeout(resolve, 500)); - } - } - dumpOutput(); - throw new Error(`${name} не поднялся за 30 секунд: ${url}`); -}; - -const getJson = async (url, options) => { - const response = await fetch(url, options); - const text = await response.text(); - let body = null; - try { - body = JSON.parse(text); - } catch { - body = text; - } - return { status: response.status, body }; -}; - -const rpc = (method, params) => getJson(`${APP}/http-api/rpc`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ jsonrpc: '2.0', method, params, id: 1 }), -}); - -// Спецификация объявляет id, total, skip и limit как uint16. Проверяем каждое -// число ответа, а не только id, потому что отрицательный total ломал уроки так же. -const outOfRange = (value, path = '$') => { - if (typeof value === 'number') { - const bad = !Number.isInteger(value) || value < 0 || value > UINT16_MAX; - return bad ? [`${path} = ${value}`] : []; - } - if (Array.isArray(value)) { - return value.flatMap((item, index) => outOfRange(item, `${path}[${index}]`)); - } - if (value && typeof value === 'object') { - return Object.entries(value).flatMap(([key, item]) => outOfRange(item, `${path}.${key}`)); - } - return []; -}; - -const run = async () => { - // Флаг --options обязателен: без него fastify-cli не читает экспорт options - // из приложения, ajv не узнаёт про format uint16 из спецификации и сборка - // схемы падает на старте. Падает молча, кодом 1 и без единой строки вывода. - start('app', 'pnpm', [ - 'exec', 'fastify', 'start', '--options', '-p', '4010', '-a', '127.0.0.1', 'custom-server/src/index.js', - ]); - - await waitFor(`${APP}/`, 'приложение'); - - await checkBodyExamples({ APP, check, getJson }); - - console.log('\nREST и RPC отдают одни и те же задачи'); - const restList = await getJson(TASKS); - const rpcList = await rpc('tasks.list', {}); - check('GET /tasks отвечает 200', restList.status === 200, `получено ${restList.status}`); - check( - 'GET /tasks отдаёт набор из data/tasks.js', - JSON.stringify(restList.body?.tasks) === JSON.stringify(expectedTasks), - JSON.stringify(restList.body?.tasks), - ); - check( - 'tasks.list отдаёт тот же набор, что REST', - JSON.stringify(rpcList.body?.result?.tasks) === JSON.stringify(restList.body?.tasks), - JSON.stringify(rpcList.body?.result?.tasks), - ); - check( - 'total равен размеру всего набора', - restList.body?.total === expectedTasks.length, - `total = ${restList.body?.total}`, - ); - - console.log('\nЗапись отбирается по пути'); - for (const task of expectedTasks) { - const one = await getJson(`${TASKS}/${task.id}`); - const viaRpc = await rpc('tasks.get', { id: task.id }); - check( - `GET /tasks/${task.id} отдаёт задачу ${task.id}`, - JSON.stringify(one.body) === JSON.stringify(task), - JSON.stringify(one.body), - ); - check( - `tasks.get id=${task.id} совпадает с REST`, - JSON.stringify(viaRpc.body?.result) === JSON.stringify(one.body), - JSON.stringify(viaRpc.body?.result), - ); - } - const missing = await getJson(`${TASKS}/999`); - check('GET /tasks/999 отвечает 404', missing.status === 404, `получено ${missing.status}`); - - console.log('\nskip и limit применяются'); - const cases = [ - ['limit=2 отдаёт первые две', { skip: 0, limit: 2 }, expectedTasks.slice(0, 2)], - ['skip=1 пропускает первую', { skip: 1, limit: 30 }, expectedTasks.slice(1)], - ['skip=1&limit=1 отдаёт вторую', { skip: 1, limit: 1 }, expectedTasks.slice(1, 2)], - ['skip за концом набора отдаёт пусто', { skip: 99, limit: 10 }, []], - ]; - for (const [name, query, expected] of cases) { - const params = new URLSearchParams(query); - const page = await getJson(`${TASKS}?${params}`); - check(name, JSON.stringify(page.body?.tasks) === JSON.stringify(expected), JSON.stringify(page.body?.tasks)); - check( - `${name}: total остаётся ${expectedTasks.length}`, - page.body?.total === expectedTasks.length, - `total = ${page.body?.total}`, - ); - } - const rpcPage = await rpc('tasks.list', { skip: 1, limit: 1 }); - check( - 'tasks.list со skip и limit совпадает с REST', - JSON.stringify(rpcPage.body?.result?.tasks) === JSON.stringify(expectedTasks.slice(1, 2)), - JSON.stringify(rpcPage.body?.result?.tasks), - ); - const badRange = await getJson(`${TASKS}?skip=-1`); - check('отрицательный skip отвечает 422', badRange.status === 422, `получено ${badRange.status}`); - - console.log('\nКоллекции: пагинация, select и вложенные ресурсы'); - // Урок example печатает ровно этот ответ: пользователей десять, поэтому - // страница за тридцатым пустая, а total остаётся десяткой. - const usersSkipped = await getJson(`${USERS}?skip=30`); - check( - '/users?skip=30 отдаёт пустую страницу с total 10', - JSON.stringify(usersSkipped.body) === JSON.stringify({ - users: [], total: expectedUsers.length, skip: 30, limit: 30, - }), - JSON.stringify(usersSkipped.body), - ); - - const postsSkipped = await getJson(`${POSTS}?skip=30`); - check( - '/posts?skip=30 отдаёт последнюю страницу', - JSON.stringify(postsSkipped.body?.posts) === JSON.stringify(expectedPosts.slice(30)), - `постов ${postsSkipped.body?.posts?.length}`, - ); - check( - '/posts?skip=30: total равен всему набору', - postsSkipped.body?.total === expectedPosts.length, - `total = ${postsSkipped.body?.total}`, - ); - - // select оставляет запрошенные поля и всегда идентификатор: без него запись - // бесполезна, и именно так параметр показан в уроке example. - const selected = await getJson(`${USERS}?select=firstName,email`); - check( - 'select оставляет только запрошенные поля и id', - JSON.stringify(Object.keys(selected.body?.users?.[0] ?? {}).sort()) === JSON.stringify(['email', 'firstName', 'id']), - JSON.stringify(selected.body?.users?.[0]), - ); - const selectedOne = await getJson(`${USERS}/1?select=lastName`); - check( - 'select работает и на одиночном ресурсе', - JSON.stringify(Object.keys(selectedOne.body ?? {}).sort()) === JSON.stringify(['id', 'lastName']), - JSON.stringify(selectedOne.body), - ); - - // Вложенный ресурс отбирает по родителю: мок отдавал здесь весь список. - const ownPosts = await getJson(`${USERS}/1/posts`); - const expectedOwn = expectedPosts.filter((post) => post.authorId === 1); - check( - '/users/1/posts отдаёт только посты автора 1', - JSON.stringify(ownPosts.body?.posts) === JSON.stringify(expectedOwn), - `постов ${ownPosts.body?.posts?.length}, ожидалось ${expectedOwn.length}`, - ); - const allPosts = await getJson(POSTS); - check( - '/users/1/posts не совпадает с /posts', - JSON.stringify(ownPosts.body) !== JSON.stringify(allPosts.body), - ); - const postComments = await getJson(`${POSTS}/1/comments`); - check( - '/posts/1/comments отдаёт только комментарии этого поста', - JSON.stringify(postComments.body?.comments) === JSON.stringify(expectedComments.filter((c) => c.postId === 1)), - JSON.stringify(postComments.body?.comments), - ); - const nestedMissing = await getJson(`${USERS}/999/posts`); - check('/users/999/posts → 404', nestedMissing.status === 404, `получено ${nestedMissing.status}`); - - console.log('\nОтсутствующая запись и авторизация по коллекциям'); - for (const [name, url] of [['users', USERS], ['posts', POSTS], ['comments', COMMENTS]]) { - const { status } = await getJson(`${url}/999`); - check(`GET /${name}/999 → 404`, status === 404, `получено ${status}`); - } - // Спецификация закрывает Bearer'ом изменение постов, комментариев и - // пользователей, а создание пользователя оставляет открытым. - const guarded = [ - ['PATCH /users/1 без токена → 401', `${USERS}/1`, 'PATCH', 401], - ['DELETE /users/1 без токена → 401', `${USERS}/1`, 'DELETE', 401], - ['PATCH /posts/1 без токена → 401', `${POSTS}/1`, 'PATCH', 401], - ['DELETE /comments/1 без токена → 401', `${COMMENTS}/1`, 'DELETE', 401], - ]; - for (const [name, url, method, expected] of guarded) { - // Content-Type проставляется только вместе с телом: fastify отвечает 400 на - // пустое тело при заявленном application/json, и проверка мерила бы не то. - const options = method === 'PATCH' - ? { - method, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title: 'x', body: 'y', firstName: 'z' }), - } - : { method }; - const { status } = await getJson(url, options); - check(name, status === expected, `получено ${status}`); - } - const createdUser = await getJson(USERS, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - email: 'john@mail.com', firstName: 'John', lastName: 'Doe', password: 'secret', - }), - }); - check('POST /users без токена → 201', createdUser.status === 201, `получено ${createdUser.status}`); - check( - 'созданный пользователь без пароля в ответе', - createdUser.body !== null && !('password' in createdUser.body), - JSON.stringify(createdUser.body), - ); - - console.log('\nRPC держит ошибки в теле, а код оставляет успешным'); - const rpcMissing = await rpc('tasks.get', { id: 999 }); - const rpcUnknown = await rpc('tasks.destroy', { id: 1 }); - check('несуществующая задача: код 200', rpcMissing.status === 200, `получено ${rpcMissing.status}`); - check('несуществующая задача: есть error', Boolean(rpcMissing.body?.error)); - check('несуществующий метод: код 200', rpcUnknown.status === 200, `получено ${rpcUnknown.status}`); - check( - 'несуществующий метод: -32601', - rpcUnknown.body?.error?.code === -32601, - JSON.stringify(rpcUnknown.body?.error), - ); - - console.log('\nКоды ответов, на них построены самостоятельные'); - const codes = [ - ['GET /nosuch → 404', `${APP}/http-api/nosuch`, { method: 'GET' }, 404], - ['DELETE /tasks → 405', TASKS, { method: 'DELETE' }, 405], - ['POST /tasks с пустым телом → 422', TASKS, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: '{}', - }, 422], - ['DELETE /tasks/1 → 204', `${TASKS}/1`, { method: 'DELETE' }, 204], - ['POST /posts без токена → 401', POSTS, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title: 'title', body: 'body' }), - }, 401], - ['GET /courses без ключа → 401', COURSES, { method: 'GET' }, 401], - ]; - for (const [name, url, options, expected] of codes) { - const { status } = await getJson(url, options); - check(name, status === expected, `получено ${status}`); - } - - const created = await getJson(TASKS, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title: 'Новая задача', description: 'Описание' }), - }); - check('POST /tasks с телом → 201', created.status === 201, `получено ${created.status}`); - check( - 'созданная задача получает статус Backlog по умолчанию', - created.body?.status === 'Backlog', - JSON.stringify(created.body), - ); - // Набор данных не меняется: сервер учебный, и мутации сделали бы уроки - // невоспроизводимыми для следующего студента. - const afterCreate = await getJson(TASKS); - check( - 'после POST набор задач не изменился', - JSON.stringify(afterCreate.body?.tasks) === JSON.stringify(expectedTasks), - JSON.stringify(afterCreate.body?.tasks), - ); - - const createdPost = await getJson(POSTS, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: 'Bearer any-value' }, - body: JSON.stringify({ title: 'title', body: 'body' }), - }); - check('POST /posts с токеном → 201', createdPost.status === 201, `получено ${createdPost.status}`); - check( - 'созданный пост несёт authorId, проставленный сервером', - Number.isInteger(createdPost.body?.authorId), - JSON.stringify(createdPost.body), - ); - - const withKey = await getJson(COURSES, { method: 'GET', headers: KEY }); - check('GET /courses с ключом → 200', withKey.status === 200, `получено ${withKey.status}`); - - const login = await getJson(LOGIN, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email: 'max@hotmail.com', password: 'password' }), - }); - check( - 'POST /login отдаёт непустой token', - typeof login.body?.token === 'string' && login.body.token.length > 0, - JSON.stringify(login.body), - ); - - - // Ровно тот запрос, на который жалуются в тикете FEEDBACK-166: параметры - // должны возвращаться из запроса, а список начинаться с шестой задачи. - const asked = await getJson(`${TASKS}?skip=5&limit=10`); - check( - 'GET /tasks?skip=5&limit=10 возвращает skip и limit из запроса', - asked.body?.skip === 5 && asked.body?.limit === 10, - JSON.stringify({ skip: asked.body?.skip, limit: asked.body?.limit }), - ); - check( - 'GET /tasks?skip=5&limit=10 отдаёт десять задач начиная с шестой', - JSON.stringify(asked.body?.tasks) === JSON.stringify(expectedTasks.slice(5, 15)), - `получено ${asked.body?.tasks?.length} записей, первая ${asked.body?.tasks?.[0]?.id}`, - ); - - // Курсы тоже обслуживает приложение, а не мок: на моке параметры не работали. - const coursesPage = await getJson(`${COURSES}?skip=1&limit=2`, { headers: KEY }); - check( - '/courses?skip=1&limit=2 применяет параметры', - JSON.stringify(coursesPage.body) === JSON.stringify({ - courses: expectedCourses.slice(1, 3), total: expectedCourses.length, skip: 1, limit: 2, - }), - JSON.stringify(coursesPage.body), - ); - - // Урок httpie показывает отправку формы (`http -f`), и спецификация объявляет - // x-www-form-urlencoded у каждой операции создания. Тело разбирает - // @fastify/formbody, а проверяет схема, поэтому проверка нужна отдельная. - const form = await getJson(USERS, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: 'email=john@mail.com&firstName=John&lastName=Doe&password=secret', - }); - check( - 'POST /users формой → 201', - form.status === 201 && form.body?.firstName === 'John', - `${form.status} ${JSON.stringify(form.body)}`, - ); - - // Урок kinds просит записать три разных кода. Совпадение любых двух делает - // задание бессмысленным, поэтому проверяется именно различие. - const lessonCodes = []; - for (const [url, options] of [ - [`${APP}/http-api/nosuch`, { method: 'GET' }], - [TASKS, { method: 'DELETE' }], - [TASKS, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }], - ]) { - const { status } = await getJson(url, options); - lessonCodes.push(status); - } - check( - 'три неудачных запроса из урока kinds дают три разных кода', - new Set(lessonCodes).size === 3, - JSON.stringify(lessonCodes), - ); - - const allowHeader = await fetch(TASKS, { method: 'DELETE' }); - check( - '405 несёт заголовок Allow', - (allowHeader.headers.get('allow') ?? '').includes('GET'), - `Allow: ${allowHeader.headers.get('allow')}`, - ); - - console.log('\nОстальные три спецификации ведут себя так же'); - // Ожидания выписаны здесь заново, а не взяты из кода: прогон утверждает - // поведение спецификаций. Матрица снята с typespec//services/, и различия - // в ней настоящие: задачи курса Postman закрыты Basic, включая чтение одной - // задачи, а у js-playwright есть только задачи с пользователями и всё открыто. - const OTHER_SPECS = [ - { - prefix: 'http-protocol', - collections: ['tasks', 'users', 'posts', 'comments'], - nested: true, - guarded: [['DELETE', '/users/1', 'bearer'], ['POST', '/posts', 'bearer']], - open: [['GET', '/tasks/1'], ['POST', '/tasks']], - }, - { - prefix: 'js-playwright', - collections: ['tasks', 'users'], - nested: false, - guarded: [], - open: [['GET', '/tasks/1'], ['POST', '/users'], ['DELETE', '/users/1']], - }, - { - prefix: 'postman', - collections: ['tasks', 'users', 'posts', 'comments'], - nested: true, - guarded: [['GET', '/tasks/1', 'basic'], ['POST', '/tasks', 'basic'], ['DELETE', '/users/1', 'bearer']], - open: [['GET', '/tasks'], ['POST', '/users']], - }, - ]; - - const bodyFor = (path) => { - if (path.startsWith('/tasks')) return { title: 'title', description: 'description' }; - if (path.startsWith('/users')) { - return { email: 'john@mail.com', firstName: 'John', lastName: 'Doe', password: 'secret' }; - } - if (path.startsWith('/posts')) return { title: 'title', body: 'body' }; - return { postId: 1, body: 'body' }; - }; - - const credentials = { - bearer: 'Bearer any-value', - basic: `Basic ${Buffer.from('user:pass').toString('base64')}`, - }; - - for (const spec of OTHER_SPECS) { - const send = (method, path, scheme) => { - const headers = {}; - if (scheme) headers.Authorization = credentials[scheme]; - const hasBody = method === 'POST' || method === 'PATCH'; - if (hasBody) headers['Content-Type'] = 'application/json'; - return getJson(`${APP}/${spec.prefix}${path}`, { - method, - headers, - body: hasBody ? JSON.stringify(bodyFor(path)) : undefined, - }); - }; - - const page = await send('GET', '/users?skip=3&limit=2'); - check( - `${spec.prefix}: skip=3&limit=2 применяются`, - JSON.stringify(page.body?.users) === JSON.stringify(expectedUsers.slice(3, 5)) - && page.body?.total === expectedUsers.length, - JSON.stringify(page.body), - ); - const selected = await send('GET', '/users?select=firstName'); - check( - `${spec.prefix}: select оставляет id и запрошенное поле`, - JSON.stringify(Object.keys(selected.body?.users?.[0] ?? {})) === JSON.stringify(['id', 'firstName']), - JSON.stringify(selected.body?.users?.[0]), - ); - const third = await send('GET', '/users/3'); - check( - `${spec.prefix}: /users/3 отдаёт третьего пользователя`, - JSON.stringify(third.body) === JSON.stringify(expectedUsers[2]), - JSON.stringify(third.body), - ); - const missing = await send('GET', '/users/999'); - check(`${spec.prefix}: /users/999 → 404`, missing.status === 404, `получено ${missing.status}`); - const wrongMethod = await send('DELETE', '/users'); - check(`${spec.prefix}: DELETE по коллекции → 405`, wrongMethod.status === 405, `получено ${wrongMethod.status}`); - const badRange = await send('GET', '/users?skip=-1'); - check(`${spec.prefix}: отрицательный skip → 422`, badRange.status === 422, `получено ${badRange.status}`); - - for (const name of ['tasks', 'users', 'posts', 'comments']) { - const declared = spec.collections.includes(name); - const { status } = await send('GET', `/${name}`); - check( - `${spec.prefix}/${name} ${declared ? 'отвечает 200' : 'не объявлена'}`, - declared ? status === 200 : status === 404, - `получено ${status}`, - ); - } - - if (spec.nested) { - const own = await send('GET', '/users/1/posts'); - check( - `${spec.prefix}: /users/1/posts отбирает по автору`, - JSON.stringify(own.body?.posts) === JSON.stringify(expectedPosts.filter((post) => post.authorId === 1)), - `постов ${own.body?.posts?.length}`, - ); - } - - for (const [method, path, scheme] of spec.guarded) { - const without = await send(method, path); - check(`${spec.prefix}: ${method} ${path} без ${scheme} → 401`, without.status === 401, `получено ${without.status}`); - const withCreds = await send(method, path, scheme); - check(`${spec.prefix}: ${method} ${path} с ${scheme} проходит`, withCreds.status < 400, `получено ${withCreds.status}`); - } - - for (const [method, path] of spec.open) { - const { status } = await send(method, path); - check(`${spec.prefix}: ${method} ${path} открыт`, status < 400, `получено ${status}`); - } - - // Создание везде 201: три спецификации объявляли обычный ответ, и урок - // api-testing курса Playwright из-за этого не проходил. - const created = await send('POST', '/users'); - check(`${spec.prefix}: POST /users → 201`, created.status === 201, `получено ${created.status}`); - } - - console.log('\nЧисла в ответах не выходят за uint16'); - for (const url of [TASKS, `${TASKS}/1`, USERS, `${USERS}/1`, POSTS, `${POSTS}/1`, COMMENTS]) { - const path = url.replace(APP, ''); - const { body } = await getJson(url, { method: 'GET' }); - const bad = outOfRange(body, path); - check(`${path} в границах uint16`, bad.length === 0, bad.join(', ')); - } - const { body: coursesBody } = await getJson(COURSES, { method: 'GET', headers: KEY }); - check('/courses в границах uint16', outOfRange(coursesBody, '/courses').length === 0); - - console.log('\nПримеры спецификации не расходятся с набором задач'); - // Документацию курса читают по спецификации, а данные отдаёт приложение, то - // есть примеры и набор это две копии. Сверка идёт поиском подстроки: разбирать - // YAML нечем, отдельная зависимость ради одной проверки того не стоит. - const spec = readFileSync(SPEC, 'utf8'); - // В примере приведены первые три задачи, а не весь набор: пример это - // иллюстрация ответа, а не его копия. Сверяется то, что показано, плюс total. - for (const task of expectedTasks.slice(0, 3)) { - check( - `пример задачи ${task.id} есть в спецификации`, - spec.includes(task.title) && spec.includes(task.description), - `нет «${task.title}»`, - ); - } - check( - 'пример Tasks объявляет реальный total', - spec.includes(`total: ${expectedTasks.length}`), - `ожидался total: ${expectedTasks.length}`, - ); - for (const user of expectedUsers.slice(0, 3)) { - check( - `пример пользователя ${user.id} есть в спецификации`, - spec.includes(user.email) && spec.includes(user.firstName), - `нет «${user.email}»`, - ); - } - for (const post of expectedPosts.slice(0, 3)) { - check(`пример поста ${post.id} есть в спецификации`, spec.includes(post.title), `нет «${post.title}»`); - } - check( - 'пример Posts объявляет реальный total', - spec.includes(`total: ${expectedPosts.length}`), - `ожидался total: ${expectedPosts.length}`, - ); - check( - 'пример Comments объявляет реальный total', - spec.includes(`total: ${expectedComments.length}`), - `ожидался total: ${expectedComments.length}`, - ); -}; - -try { - await run(); -} catch (error) { - console.log(`\nПрогон прерван: ${error.message}`); - failures.push('прогон не дошёл до конца'); -} finally { - await stopAll(); -} - -if (failures.length > 0) { - console.log(`\nПровалов: ${failures.length}`); - process.exit(1); -} - -console.log('\nВсе проверки прошли'); -process.exit(0); diff --git a/test/body-examples.test.ts b/test/body-examples.test.ts new file mode 100644 index 0000000..2caf213 --- /dev/null +++ b/test/body-examples.test.ts @@ -0,0 +1,89 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { FastifyInstance } from 'fastify'; + +import { buildApp, createClient } from './helpers.ts'; + +const fields = { title: 'Check the automation task', description: 'Review the test request' }; + +let app: FastifyInstance; +let client: ReturnType; + +beforeAll(async () => { + app = await buildApp(); + client = createClient(app); +}); + +afterAll(() => app.close()); + +describe('Форматы тела учебных запросов', () => { + it('форма доступна и задаёт имена обоих полей', async () => { + const page = await client.send('/http-protocol/form'); + expect(page.status).toBe(200); + expect(page.body).toContain('name="title"'); + expect(page.body).toContain('name="description"'); + }); + + it('браузерная форма возвращает прочитанные значения', async () => { + const reply = await client.send('/http-protocol/form', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + payload: new URLSearchParams(fields).toString(), + }); + expect(reply.status).toBe(200); + expect(reply.body).toBe(`title=${fields.title}\ndescription=${fields.description}\n`); + }); + + it('JSON сохраняет типы и отражает заголовки', async () => { + const reply = await client.json( + '/http-protocol/body', + 'POST', + { ...fields, priority: 2, urgent: false }, + { Accept: 'application/json' }, + ); + expect(reply.status).toBe(200); + expect(reply.body.json.priority).toBe(2); + expect(reply.body.json.urgent).toBe(false); + expect(reply.body.headers.Accept).toBe('application/json'); + }); + + it('кодированные поля декодируются с пробелами', async () => { + const reply = await client.send('/http-protocol/body', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + payload: new URLSearchParams(fields).toString(), + }); + expect(reply.status).toBe(200); + expect(reply.body.form.title).toBe(fields.title); + expect(reply.body.json).toBeNull(); + }); + + it('multipart передаёт содержимое файла и текстовое поле', async () => { + const body = new FormData(); + body.set('title', fields.title); + body.set( + 'attachment', + new Blob(['Please check the automation task\n'], { type: 'text/plain' }), + 'http-body-note.txt', + ); + const response = await app.inject({ + url: '/http-protocol/body', + method: 'POST', + payload: body, + }); + const reply = JSON.parse(response.body); + expect(response.statusCode).toBe(200); + expect(reply.form.title).toBe(fields.title); + expect(reply.files.attachment).toBe('Please check the automation task\n'); + expect(reply.headers['Content-Type']).toContain('boundary='); + }); + + it('PNG имеет заявленный размер и сигнатуру', async () => { + const response = await app.inject({ url: '/http-protocol/image.png' }); + const data = response.rawPayload; + expect(response.statusCode).toBe(200); + expect(String(response.headers['content-type'])).toMatch(/^image\/png/); + expect(Number(response.headers['content-length'])).toBe(data.length); + expect(data.length).toBe(8090); + expect([...data.subarray(0, 8)]).toEqual([137, 80, 78, 71, 13, 10, 26, 10]); + }); +}); diff --git a/test/collections.test.ts b/test/collections.test.ts new file mode 100644 index 0000000..34fbd3d --- /dev/null +++ b/test/collections.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; + +import { + page, parseRange, parseSelect, project, +} from '../custom-server/src/collections.js'; + +describe('parseSelect', () => { + it('пустое значение означает «все поля»', () => { + expect(parseSelect(undefined)).toBeNull(); + expect(parseSelect('')).toBeNull(); + }); + + it('разбирает строку и повторяющийся параметр', () => { + expect(parseSelect('firstName, email')).toEqual(['firstName', 'email']); + expect(parseSelect(['a,b', 'c'])).toEqual(['a', 'b', 'c']); + }); +}); + +describe('project', () => { + const item = { id: 1, a: 'x', b: 'y' }; + + it('без списка полей отдаёт запись целиком', () => { + expect(project(item, null)).toBe(item); + }); + + it('id остаётся всегда, порядок полей берётся из модели', () => { + expect(Object.keys(project(item, ['b', 'a']))).toEqual(['id', 'a', 'b']); + expect(project(item, ['b'])).toEqual({ id: 1, b: 'y' }); + }); +}); + +describe('parseRange', () => { + it('подставляет значения по умолчанию', () => { + expect(parseRange({})).toEqual({ skip: 0, limit: 30 }); + }); + + it('негодное значение даёт null', () => { + expect(parseRange({ skip: -1 })).toBeNull(); + expect(parseRange({ limit: 'x' })).toBeNull(); + expect(parseRange({ skip: 1.5 })).toBeNull(); + }); +}); + +describe('page', () => { + it('total это размер всего набора, а не страницы', () => { + const items = [{ id: 1 }, { id: 2 }, { id: 3 }]; + expect(page({ items, envelope: 'items', skip: 1, limit: 1 })).toEqual({ + items: [{ id: 2 }], total: 3, skip: 1, limit: 1, + }); + }); +}); diff --git a/test/helpers.ts b/test/helpers.ts new file mode 100644 index 0000000..1dddf3d --- /dev/null +++ b/test/helpers.ts @@ -0,0 +1,64 @@ +import Fastify from 'fastify'; +import type { FastifyInstance, InjectOptions } from 'fastify'; + +import app, { options } from '../custom-server/src/index.js'; + +export const UINT16_MAX = 65535; + +// Приложение собирается так же, как это делает `fastify start --options`: +// options отдаёт настройку ajv (формат uint16), а без неё сборка схемы падает +// на старте. Поэтому экземпляр создаётся с options, а не голым Fastify(). +export const buildApp = async (): Promise => { + const instance = Fastify(options); + await instance.register(app); + await instance.ready(); + return instance; +}; + +export interface Reply { + status: number; + body: any; + headers: Record; +} + +export const createClient = (instance: FastifyInstance) => { + const send = async (url: string, opts: InjectOptions = {}): Promise => { + const response = await instance.inject({ url, method: 'GET', ...opts }); + const text = response.body; + let body: any = text; + try { + body = JSON.parse(text); + } catch { + // не JSON, остаётся текстом + } + return { status: response.statusCode, body, headers: response.headers }; + }; + + const json = (url: string, method: InjectOptions['method'], payload?: unknown, headers = {}) => send(url, { + method, + headers: payload === undefined ? headers : { 'content-type': 'application/json', ...headers }, + payload: payload === undefined ? undefined : JSON.stringify(payload), + }); + + const rpc = (method: string, params?: unknown) => json('/http-api/rpc', 'POST', { + jsonrpc: '2.0', method, params, id: 1, + }); + + return { send, json, rpc }; +}; + +// Спецификация объявляет id, total, skip и limit как uint16. Проверяется каждое +// число ответа, а не только id: отрицательный total ломал уроки так же. +export const outOfRange = (value: unknown, path = '$'): string[] => { + if (typeof value === 'number') { + const bad = !Number.isInteger(value) || value < 0 || value > UINT16_MAX; + return bad ? [`${path} = ${value}`] : []; + } + if (Array.isArray(value)) { + return value.flatMap((item, index) => outOfRange(item, `${path}[${index}]`)); + } + if (value && typeof value === 'object') { + return Object.entries(value).flatMap(([key, item]) => outOfRange(item, `${path}.${key}`)); + } + return []; +}; diff --git a/test/http-api.test.ts b/test/http-api.test.ts new file mode 100644 index 0000000..92e9139 --- /dev/null +++ b/test/http-api.test.ts @@ -0,0 +1,268 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { FastifyInstance } from 'fastify'; + +import expectedComments from '../custom-server/src/data/comments.js'; +import expectedCourses from '../custom-server/src/data/courses.js'; +import expectedPosts from '../custom-server/src/data/posts.js'; +import expectedTasks from '../custom-server/src/data/tasks.js'; +import expectedUsers from '../custom-server/src/data/users.js'; +import { buildApp, createClient, outOfRange } from './helpers.ts'; + +const API = '/http-api'; +const TASKS = `${API}/tasks`; +const USERS = `${API}/users`; +const POSTS = `${API}/posts`; +const COMMENTS = `${API}/comments`; +const COURSES = `${API}/courses`; +const KEY = { 'X-API-KEY': 'any-value' }; + +let app: FastifyInstance; +let client: ReturnType; + +beforeAll(async () => { + app = await buildApp(); + client = createClient(app); +}); + +afterAll(() => app.close()); + +describe('REST и RPC отдают одни и те же задачи', () => { + it('GET /tasks отдаёт набор из data/tasks', async () => { + const list = await client.send(TASKS); + expect(list.status).toBe(200); + expect(list.body.tasks).toEqual(expectedTasks); + expect(list.body.total).toBe(expectedTasks.length); + }); + + it('tasks.list отдаёт тот же набор, что REST', async () => { + const rest = await client.send(TASKS); + const rpc = await client.rpc('tasks.list', {}); + expect(rpc.body.result.tasks).toEqual(rest.body.tasks); + }); + + it.each(expectedTasks.map((task) => [task.id, task] as const))( + 'задача %i отбирается по пути, RPC совпадает с REST', + async (id, task) => { + const one = await client.send(`${TASKS}/${id}`); + const viaRpc = await client.rpc('tasks.get', { id }); + expect(one.body).toEqual(task); + expect(viaRpc.body.result).toEqual(one.body); + }, + ); + + it('GET /tasks/999 отвечает 404', async () => { + expect((await client.send(`${TASKS}/999`)).status).toBe(404); + }); +}); + +describe('skip и limit применяются', () => { + it.each([ + ['limit=2 отдаёт первые две', { skip: 0, limit: 2 }, expectedTasks.slice(0, 2)], + ['skip=1 пропускает первую', { skip: 1, limit: 30 }, expectedTasks.slice(1)], + ['skip=1&limit=1 отдаёт вторую', { skip: 1, limit: 1 }, expectedTasks.slice(1, 2)], + ['skip за концом набора отдаёт пусто', { skip: 99, limit: 10 }, []], + ])('%s', async (_name, query, expected) => { + const page = await client.send(`${TASKS}?${new URLSearchParams(query as any)}`); + expect(page.body.tasks).toEqual(expected); + expect(page.body.total).toBe(expectedTasks.length); + }); + + it('tasks.list со skip и limit совпадает с REST', async () => { + const rpc = await client.rpc('tasks.list', { skip: 1, limit: 1 }); + expect(rpc.body.result.tasks).toEqual(expectedTasks.slice(1, 2)); + }); + + it('отрицательный skip отвечает 422', async () => { + expect((await client.send(`${TASKS}?skip=-1`)).status).toBe(422); + }); + + // Запрос из тикета FEEDBACK-166: параметры возвращаются из запроса, а список + // начинается с шестой задачи. + it('?skip=5&limit=10 возвращает skip и limit и десять задач с шестой', async () => { + const asked = await client.send(`${TASKS}?skip=5&limit=10`); + expect(asked.body.skip).toBe(5); + expect(asked.body.limit).toBe(10); + expect(asked.body.tasks).toEqual(expectedTasks.slice(5, 15)); + }); +}); + +describe('Коллекции: пагинация, select и вложенные ресурсы', () => { + // Урок example печатает ровно этот ответ. + it('/users?skip=30 отдаёт пустую страницу с total 10', async () => { + const reply = await client.send(`${USERS}?skip=30`); + expect(reply.body).toEqual({ + users: [], total: expectedUsers.length, skip: 30, limit: 30, + }); + }); + + it('/posts?skip=30 отдаёт последнюю страницу и полный total', async () => { + const reply = await client.send(`${POSTS}?skip=30`); + expect(reply.body.posts).toEqual(expectedPosts.slice(30)); + expect(reply.body.total).toBe(expectedPosts.length); + }); + + it('select оставляет только запрошенные поля и id', async () => { + const reply = await client.send(`${USERS}?select=firstName,email`); + expect(Object.keys(reply.body.users[0]).sort()).toEqual(['email', 'firstName', 'id']); + }); + + it('select работает и на одиночном ресурсе', async () => { + const reply = await client.send(`${USERS}/1?select=lastName`); + expect(Object.keys(reply.body).sort()).toEqual(['id', 'lastName']); + }); + + it('/users/1/posts отдаёт только посты автора 1 и не совпадает с /posts', async () => { + const own = await client.send(`${USERS}/1/posts`); + expect(own.body.posts).toEqual(expectedPosts.filter((post) => post.authorId === 1)); + expect(own.body).not.toEqual((await client.send(POSTS)).body); + }); + + it('/posts/1/comments отдаёт только комментарии этого поста', async () => { + const reply = await client.send(`${POSTS}/1/comments`); + expect(reply.body.comments).toEqual(expectedComments.filter((c) => c.postId === 1)); + }); + + it('/users/999/posts → 404', async () => { + expect((await client.send(`${USERS}/999/posts`)).status).toBe(404); + }); + + it('/courses?skip=1&limit=2 применяет параметры', async () => { + const reply = await client.send(`${COURSES}?skip=1&limit=2`, { headers: KEY }); + expect(reply.body).toEqual({ + courses: expectedCourses.slice(1, 3), total: expectedCourses.length, skip: 1, limit: 2, + }); + }); +}); + +describe('Отсутствующая запись и авторизация по коллекциям', () => { + it.each([['users', USERS], ['posts', POSTS], ['comments', COMMENTS]])( + 'GET /%s/999 → 404', + async (_name, url) => { + expect((await client.send(`${url}/999`)).status).toBe(404); + }, + ); + + // Спецификация закрывает Bearer'ом изменение постов, комментариев и + // пользователей, а создание пользователя оставляет открытым. + it.each([ + ['PATCH /users/1', `${USERS}/1`, 'PATCH'], + ['DELETE /users/1', `${USERS}/1`, 'DELETE'], + ['PATCH /posts/1', `${POSTS}/1`, 'PATCH'], + ['DELETE /comments/1', `${COMMENTS}/1`, 'DELETE'], + ] as const)('%s без токена → 401', async (_name, url, method) => { + // Content-Type только вместе с телом: fastify отвечает 400 на пустое тело + // при заявленном application/json, и проверка мерила бы не то. + const reply = method === 'PATCH' + ? await client.json(url, method, { title: 'x', body: 'y', firstName: 'z' }) + : await client.json(url, method); + expect(reply.status).toBe(401); + }); + + it('POST /users без токена → 201, пароля в ответе нет', async () => { + const created = await client.json(USERS, 'POST', { + email: 'john@mail.com', firstName: 'John', lastName: 'Doe', password: 'secret', + }); + expect(created.status).toBe(201); + expect(created.body).not.toHaveProperty('password'); + }); + + // Урок httpie показывает отправку формы (`http -f`), а тело разбирает + // @fastify/formbody и проверяет схема. + it('POST /users формой → 201', async () => { + const form = await client.send(USERS, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + payload: 'email=john@mail.com&firstName=John&lastName=Doe&password=secret', + }); + expect(form.status).toBe(201); + expect(form.body.firstName).toBe('John'); + }); +}); + +describe('RPC держит ошибки в теле, а код оставляет успешным', () => { + it('несуществующая задача: код 200 и error', async () => { + const reply = await client.rpc('tasks.get', { id: 999 }); + expect(reply.status).toBe(200); + expect(reply.body.error).toBeTruthy(); + }); + + it('несуществующий метод: код 200 и -32601', async () => { + const reply = await client.rpc('tasks.destroy', { id: 1 }); + expect(reply.status).toBe(200); + expect(reply.body.error.code).toBe(-32601); + }); +}); + +describe('Коды ответов, на них построены самостоятельные', () => { + it('GET /nosuch → 404', async () => { + expect((await client.send(`${API}/nosuch`)).status).toBe(404); + }); + + it('DELETE /tasks → 405 с заголовком Allow', async () => { + const reply = await client.send(TASKS, { method: 'DELETE' }); + expect(reply.status).toBe(405); + expect(String(reply.headers.allow ?? '')).toContain('GET'); + }); + + it('POST /tasks с пустым телом → 422', async () => { + expect((await client.json(TASKS, 'POST', {})).status).toBe(422); + }); + + it('DELETE /tasks/1 → 204', async () => { + expect((await client.send(`${TASKS}/1`, { method: 'DELETE' })).status).toBe(204); + }); + + it('POST /posts без токена → 401', async () => { + expect((await client.json(POSTS, 'POST', { title: 'title', body: 'body' })).status).toBe(401); + }); + + it('GET /courses без ключа → 401, с ключом → 200', async () => { + expect((await client.send(COURSES)).status).toBe(401); + expect((await client.send(COURSES, { headers: KEY })).status).toBe(200); + }); + + it('POST /tasks с телом → 201, статус Backlog, набор не меняется', async () => { + const created = await client.json(TASKS, 'POST', { title: 'Новая задача', description: 'Описание' }); + expect(created.status).toBe(201); + expect(created.body.status).toBe('Backlog'); + // Сервер учебный, и мутации сделали бы уроки невоспроизводимыми. + expect((await client.send(TASKS)).body.tasks).toEqual(expectedTasks); + }); + + it('POST /posts с токеном → 201 и authorId от сервера', async () => { + const created = await client.json( + POSTS, 'POST', { title: 'title', body: 'body' }, { Authorization: 'Bearer any-value' }, + ); + expect(created.status).toBe(201); + expect(Number.isInteger(created.body.authorId)).toBe(true); + }); + + it('POST /login отдаёт непустой token', async () => { + const login = await client.json(`${API}/login`, 'POST', { email: 'max@hotmail.com', password: 'password' }); + expect(typeof login.body.token).toBe('string'); + expect(login.body.token.length).toBeGreaterThan(0); + }); + + // Урок kinds просит записать три разных кода. Совпадение любых двух делает + // задание бессмысленным. + it('три неудачных запроса из урока kinds дают три разных кода', async () => { + const codes = [ + (await client.send(`${API}/nosuch`)).status, + (await client.send(TASKS, { method: 'DELETE' })).status, + (await client.json(TASKS, 'POST', {})).status, + ]; + expect(new Set(codes).size).toBe(3); + }); +}); + +describe('Числа в ответах не выходят за uint16', () => { + it.each([ + [TASKS], [`${TASKS}/1`], [USERS], [`${USERS}/1`], [POSTS], [`${POSTS}/1`], [COMMENTS], + ])('%s', async (url) => { + expect(outOfRange((await client.send(url)).body, url)).toEqual([]); + }); + + it(COURSES, async () => { + expect(outOfRange((await client.send(COURSES, { headers: KEY })).body, COURSES)).toEqual([]); + }); +}); diff --git a/test/other-specs.test.ts b/test/other-specs.test.ts new file mode 100644 index 0000000..a26622e --- /dev/null +++ b/test/other-specs.test.ts @@ -0,0 +1,131 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { FastifyInstance } from 'fastify'; + +import expectedPosts from '../custom-server/src/data/posts.js'; +import expectedUsers from '../custom-server/src/data/users.js'; +import { buildApp, createClient } from './helpers.ts'; + +// Ожидания выписаны здесь заново, а не взяты из кода: тест утверждает поведение +// спецификаций. Матрица снята с typespec//services/, и различия в ней +// настоящие: задачи курса Postman закрыты Basic, включая чтение одной задачи, а +// у js-playwright есть только задачи с пользователями и всё открыто. +type Scheme = 'bearer' | 'basic'; +type Call = [method: string, path: string, scheme?: Scheme]; + +const SPECS: { + prefix: string; + collections: string[]; + nested: boolean; + guarded: Call[]; + open: Call[]; +}[] = [ + { + prefix: 'http-protocol', + collections: ['tasks', 'users', 'posts', 'comments'], + nested: true, + guarded: [['DELETE', '/users/1', 'bearer'], ['POST', '/posts', 'bearer']], + open: [['GET', '/tasks/1'], ['POST', '/tasks']], + }, + { + prefix: 'js-playwright', + collections: ['tasks', 'users'], + nested: false, + guarded: [], + open: [['GET', '/tasks/1'], ['POST', '/users'], ['DELETE', '/users/1']], + }, + { + prefix: 'postman', + collections: ['tasks', 'users', 'posts', 'comments'], + nested: true, + guarded: [['GET', '/tasks/1', 'basic'], ['POST', '/tasks', 'basic'], ['DELETE', '/users/1', 'bearer']], + open: [['GET', '/tasks'], ['POST', '/users']], + }, +]; + +const bodyFor = (path: string) => { + if (path.startsWith('/tasks')) return { title: 'title', description: 'description' }; + if (path.startsWith('/users')) { + return { email: 'john@mail.com', firstName: 'John', lastName: 'Doe', password: 'secret' }; + } + if (path.startsWith('/posts')) return { title: 'title', body: 'body' }; + return { postId: 1, body: 'body' }; +}; + +const credentials: Record = { + bearer: 'Bearer any-value', + basic: `Basic ${Buffer.from('user:pass').toString('base64')}`, +}; + +let app: FastifyInstance; +let client: ReturnType; + +beforeAll(async () => { + app = await buildApp(); + client = createClient(app); +}); + +afterAll(() => app.close()); + +describe.each(SPECS)('$prefix', (spec) => { + const send = (method: string, path: string, scheme?: Scheme) => { + const hasBody = method === 'POST' || method === 'PATCH'; + return client.json( + `/${spec.prefix}${path}`, + method as any, + hasBody ? bodyFor(path) : undefined, + scheme ? { Authorization: credentials[scheme] } : {}, + ); + }; + + it('skip=3&limit=2 применяются', async () => { + const page = await send('GET', '/users?skip=3&limit=2'); + expect(page.body.users).toEqual(expectedUsers.slice(3, 5)); + expect(page.body.total).toBe(expectedUsers.length); + }); + + it('select оставляет id и запрошенное поле', async () => { + const selected = await send('GET', '/users?select=firstName'); + expect(Object.keys(selected.body.users[0])).toEqual(['id', 'firstName']); + }); + + it('/users/3 отдаёт третьего пользователя', async () => { + expect((await send('GET', '/users/3')).body).toEqual(expectedUsers[2]); + }); + + it('/users/999 → 404', async () => { + expect((await send('GET', '/users/999')).status).toBe(404); + }); + + it('DELETE по коллекции → 405', async () => { + expect((await send('DELETE', '/users')).status).toBe(405); + }); + + it('отрицательный skip → 422', async () => { + expect((await send('GET', '/users?skip=-1')).status).toBe(422); + }); + + it.each(['tasks', 'users', 'posts', 'comments'])('/%s', async (name) => { + const { status } = await send('GET', `/${name}`); + expect(status).toBe(spec.collections.includes(name) ? 200 : 404); + }); + + it.runIf(spec.nested)('/users/1/posts отбирает по автору', async () => { + const own = await send('GET', '/users/1/posts'); + expect(own.body.posts).toEqual(expectedPosts.filter((post) => post.authorId === 1)); + }); + + it.each(spec.guarded)('%s %s закрыт: без учётных данных 401, с ними проходит', async (method, path, scheme) => { + expect((await send(method, path)).status).toBe(401); + expect((await send(method, path, scheme)).status).toBeLessThan(400); + }); + + it.each(spec.open)('%s %s открыт', async (method, path) => { + expect((await send(method, path)).status).toBeLessThan(400); + }); + + // Создание везде 201: три спецификации объявляли обычный ответ, и урок + // api-testing курса Playwright из-за этого не проходил. + it('POST /users → 201', async () => { + expect((await send('POST', '/users')).status).toBe(201); + }); +}); diff --git a/test/spec-examples.test.ts b/test/spec-examples.test.ts new file mode 100644 index 0000000..6e16167 --- /dev/null +++ b/test/spec-examples.test.ts @@ -0,0 +1,38 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +import expectedComments from '../custom-server/src/data/comments.js'; +import expectedPosts from '../custom-server/src/data/posts.js'; +import expectedTasks from '../custom-server/src/data/tasks.js'; +import expectedUsers from '../custom-server/src/data/users.js'; + +// Документацию курса читают по спецификации, а данные отдаёт приложение, то +// есть примеры и набор это две копии. Сверка идёт поиском подстроки: разбирать +// YAML нечем, отдельная зависимость ради одной проверки того не стоит. +const spec = readFileSync('./tsp-output/http-api/@typespec/openapi3/openapi.1.0.yaml', 'utf8'); + +// В примере приведены первые три записи, а не весь набор: пример это +// иллюстрация ответа, а не его копия. Сверяется то, что показано, плюс total. +describe('Примеры спецификации не расходятся с наборами', () => { + it.each(expectedTasks.slice(0, 3))('пример задачи $id', (task) => { + expect(spec).toContain(task.title); + expect(spec).toContain(task.description); + }); + + it.each(expectedUsers.slice(0, 3))('пример пользователя $id', (user) => { + expect(spec).toContain(user.email); + expect(spec).toContain(user.firstName); + }); + + it.each(expectedPosts.slice(0, 3))('пример поста $id', (post) => { + expect(spec).toContain(post.title); + }); + + it.each([ + ['Tasks', expectedTasks.length], + ['Posts', expectedPosts.length], + ['Comments', expectedComments.length], + ])('пример %s объявляет реальный total', (_name, total) => { + expect(spec).toContain(`total: ${total}`); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..ca028e7 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +// Корень это корень репозитория: glue и документация читают спецификации по +// путям вида tsp-output/..., а они относительные. Поэтому перед тестами нужна +// скомпилированная спецификация (`mise run compile`). +export default defineConfig({ + test: { + include: ['test/**/*.test.ts'], + }, +}); From 3f053c425e9b94812f97cb5053906db194219de1 Mon Sep 17 00:00:00 2001 From: Kirill Mokevnin Date: Wed, 14 Oct 2026 16:20:29 -0400 Subject: [PATCH 3/4] =?UTF-8?q?refactor:=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B2?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B8=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=20=D0=BD=D0=B0=20TypeScript?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node 26 снимает типы сам, сборки нет. Типы проверяет tsc (noEmit). Co-Authored-By: Claude Sonnet 5.5 --- .../{body-examples.js => body-examples.ts} | 15 +- .../src/{collections.js => collections.ts} | 42 ++++-- .../src/data/{comments.js => comments.ts} | 0 .../src/data/{courses.js => courses.ts} | 0 custom-server/src/data/{posts.js => posts.ts} | 0 custom-server/src/data/{tasks.js => tasks.ts} | 0 custom-server/src/data/{users.js => users.ts} | 0 custom-server/src/generated/fastify.gen.ts | 2 +- custom-server/src/generated/index.ts | 2 +- custom-server/src/handlers.ts | 73 +++++++--- custom-server/src/{index.js => index.ts} | 39 ++++-- custom-server/src/openapi-spec.ts | 18 +-- custom-server/src/{rpc.js => rpc.ts} | 25 +++- .../src/{tasks-store.js => tasks-store.ts} | 25 +++- openapi-ts.config.ts | 2 +- package.json | 3 +- pnpm-lock.yaml | 132 +++++++++++------- test/app.test.ts | 77 ++++++++++ test/collections.test.ts | 2 +- test/helpers.ts | 2 +- test/http-api.test.ts | 10 +- test/other-specs.test.ts | 4 +- test/spec-examples.test.ts | 8 +- tsconfig.json | 17 ++- 24 files changed, 350 insertions(+), 148 deletions(-) rename custom-server/src/{body-examples.js => body-examples.ts} (64%) rename custom-server/src/{collections.js => collections.ts} (75%) rename custom-server/src/data/{comments.js => comments.ts} (100%) rename custom-server/src/data/{courses.js => courses.ts} (100%) rename custom-server/src/data/{posts.js => posts.ts} (100%) rename custom-server/src/data/{tasks.js => tasks.ts} (100%) rename custom-server/src/data/{users.js => users.ts} (100%) rename custom-server/src/{index.js => index.ts} (80%) rename custom-server/src/{rpc.js => rpc.ts} (74%) rename custom-server/src/{tasks-store.js => tasks-store.ts} (64%) create mode 100644 test/app.test.ts diff --git a/custom-server/src/body-examples.js b/custom-server/src/body-examples.ts similarity index 64% rename from custom-server/src/body-examples.js rename to custom-server/src/body-examples.ts index e5522ad..1ba843e 100644 --- a/custom-server/src/body-examples.js +++ b/custom-server/src/body-examples.ts @@ -1,14 +1,15 @@ import multipart from "@fastify/multipart"; +import type { FastifyInstance } from "fastify"; // Изолированный учебный обработчик отражает данные без изменения коллекций. -export default async function setUpBodyExamples(app) { +export default async function setUpBodyExamples(app: FastifyInstance) { await app.register(multipart, { limits: { files: 1, fields: 10, parts: 11, fileSize: 65536, fieldSize: 4096 }, }); app.get("/http-protocol/form", (_req, res) => res.sendFile("http-protocol/form.html")); app.post("/http-protocol/form", (req, res) => { - const { title = "", description = "" } = req.body ?? {}; + const { title = "", description = "" } = (req.body ?? {}) as Record; return res .type("text/plain; charset=utf-8") .send(`title=${title}\ndescription=${description}\n`); @@ -17,22 +18,24 @@ export default async function setUpBodyExamples(app) { app.get("/http-protocol/image.png", (_req, res) => res.sendFile("http-protocol/image.png")); app.post("/http-protocol/body", async (req) => { - const headers = { "Content-Type": req.headers["content-type"] }; + const headers: Record = { "Content-Type": req.headers["content-type"] }; if (req.headers.accept) headers.Accept = req.headers.accept; - const result = { headers, json: null, form: Object.create(null), files: Object.create(null) }; + const result: { headers: typeof headers; json: unknown; form: Record; files: Record } = { + headers, json: null, form: Object.create(null), files: Object.create(null), + }; if (req.isMultipart()) { for await (const part of req.parts()) { if (part.type === "file") { const data = await part.toBuffer(); result.files[part.fieldname] = data.toString("utf8"); } else { - result.form[part.fieldname] = part.value; + result.form[part.fieldname] = (part as { value: unknown }).value; } } } else if (req.headers["content-type"]?.startsWith("application/json")) { result.json = req.body; } else { - result.form = req.body ?? {}; + result.form = (req.body ?? {}) as Record; } return result; }); diff --git a/custom-server/src/collections.js b/custom-server/src/collections.ts similarity index 75% rename from custom-server/src/collections.js rename to custom-server/src/collections.ts index e02a691..61b68d7 100644 --- a/custom-server/src/collections.js +++ b/custom-server/src/collections.ts @@ -12,10 +12,21 @@ export const DEFAULT_LIMIT = 30; +export interface Identified { + id: number; +} + +export interface Range { + skip: number; + limit: number; +} + +type Param = string | number | null | undefined; + // null означает «значение есть, но негодное». Вызывающий сам решает, каким кодом // или ошибкой на это ответить: у REST это 422, у JSON-RPC код -32602. -export const parseRange = ({ skip, limit } = {}) => { - const parse = (value, fallback) => { +export const parseRange = ({ skip, limit }: { skip?: Param; limit?: Param } = {}): Range | null => { + const parse = (value: Param, fallback: number): number | null => { if (value === undefined || value === null || value === '') return fallback; const number = Number(value); if (!Number.isInteger(number) || number < 0) return null; @@ -30,7 +41,7 @@ export const parseRange = ({ skip, limit } = {}) => { // select приходит либо строкой «firstName,email», либо повторяющимся параметром, // и тогда fastify отдаёт массив. Обе формы приводятся к списку полей. -export const parseSelect = (select) => { +export const parseSelect = (select?: string | string[] | null): string[] | null => { if (select === undefined || select === null || select === '') return null; const raw = Array.isArray(select) ? select : [select]; const fields = raw.flatMap((value) => String(value).split(',')).map((value) => value.trim()); @@ -42,18 +53,24 @@ export const parseSelect = (select) => { // Поля идут в порядке модели, а не в порядке параметра: так ответ на // `?select=firstName,email` совпадает с тем, что напечатано в уроке example, и в // целом устойчив к порядку, в котором клиент перечислил поля. -export const project = (item, fields) => { +export const project = (item: T, fields: string[] | null): Partial => { if (fields === null) return item; const wanted = new Set(fields); return Object.fromEntries( Object.entries(item).filter(([key]) => key === 'id' || wanted.has(key)), - ); + ) as Partial; }; // total это размер всего набора, а не выданной страницы: по нему клиент понимает, // есть ли записи за пределами limit. -export const page = ({ +export const page = ({ items, envelope, skip, limit, fields = null, +}: { + items: T[]; + envelope: string; + skip: number; + limit: number; + fields?: string[] | null; }) => ({ [envelope]: items.slice(skip, skip + limit).map((item) => project(item, fields)), total: items.length, @@ -61,15 +78,18 @@ export const page = ({ limit, }); -export const findById = (items, id) => items.find((item) => item.id === Number(id)); +export const findById = (items: T[], id: Param): T | undefined => items.find((item) => item.id === Number(id)); -export const nextId = (items) => items.reduce((max, item) => Math.max(max, item.id), 0) + 1; +export const nextId = (items: Identified[]): number => items.reduce((max, item) => Math.max(max, item.id), 0) + 1; -export const isFilled = (value) => typeof value === 'string' && value.length > 0; +export const isFilled = (value: unknown): boolean => typeof value === 'string' && value.length > 0; // Пустая строка не проходит: в спецификации у текстовых полей стоит @minLength(1). -export const validateFields = (dto = {}, { required = [], optional = [], partial = false } = {}) => { - const problems = []; +export const validateFields = ( + dto: Record = {}, + { required = [], optional = [], partial = false }: { required?: string[]; optional?: string[]; partial?: boolean } = {}, +): string[] => { + const problems: string[] = []; for (const field of required) { const value = dto[field]; diff --git a/custom-server/src/data/comments.js b/custom-server/src/data/comments.ts similarity index 100% rename from custom-server/src/data/comments.js rename to custom-server/src/data/comments.ts diff --git a/custom-server/src/data/courses.js b/custom-server/src/data/courses.ts similarity index 100% rename from custom-server/src/data/courses.js rename to custom-server/src/data/courses.ts diff --git a/custom-server/src/data/posts.js b/custom-server/src/data/posts.ts similarity index 100% rename from custom-server/src/data/posts.js rename to custom-server/src/data/posts.ts diff --git a/custom-server/src/data/tasks.js b/custom-server/src/data/tasks.ts similarity index 100% rename from custom-server/src/data/tasks.js rename to custom-server/src/data/tasks.ts diff --git a/custom-server/src/data/users.js b/custom-server/src/data/users.ts similarity index 100% rename from custom-server/src/data/users.js rename to custom-server/src/data/users.ts diff --git a/custom-server/src/generated/fastify.gen.ts b/custom-server/src/generated/fastify.gen.ts index 87fa803..979c653 100644 --- a/custom-server/src/generated/fastify.gen.ts +++ b/custom-server/src/generated/fastify.gen.ts @@ -2,7 +2,7 @@ import type { RouteHandler } from 'fastify'; -import type { AuthServiceCreateData, AuthServiceCreateResponses, CommentServiceCreateData, CommentServiceCreateResponses, CommentServiceDeleteData, CommentServiceDeleteErrors, CommentServiceDeleteResponses, CommentServiceGetData, CommentServiceGetErrors, CommentServiceGetResponses, CommentServiceListData, CommentServiceListResponses, CommentServiceUpdateData, CommentServiceUpdateErrors, CommentServiceUpdateResponses, CourseServiceCreateData, CourseServiceCreateResponses, CourseServiceDeleteData, CourseServiceDeleteResponses, CourseServiceGetData, CourseServiceGetResponses, CourseServiceListData, CourseServiceListResponses, CourseServiceUpdateData, CourseServiceUpdateResponses, PostServiceCreateData, PostServiceCreateResponses, PostServiceDeleteData, PostServiceDeleteErrors, PostServiceDeleteResponses, PostServiceGetCommentsData, PostServiceGetCommentsResponses, PostServiceGetData, PostServiceGetErrors, PostServiceGetResponses, PostServiceListData, PostServiceListResponses, PostServiceUpdateData, PostServiceUpdateErrors, PostServiceUpdateResponses, TaskServiceCreateData, TaskServiceCreateResponses, TaskServiceDeleteData, TaskServiceDeleteErrors, TaskServiceDeleteResponses, TaskServiceGetData, TaskServiceGetErrors, TaskServiceGetResponses, TaskServiceListData, TaskServiceListResponses, TaskServiceUpdateData, TaskServiceUpdateErrors, TaskServiceUpdateResponses, UserServiceCreateData, UserServiceCreateResponses, UserServiceDeleteData, UserServiceDeleteErrors, UserServiceDeleteResponses, UserServiceGetCommentsData, UserServiceGetCommentsResponses, UserServiceGetData, UserServiceGetErrors, UserServiceGetPostsData, UserServiceGetPostsResponses, UserServiceGetResponses, UserServiceListData, UserServiceListResponses, UserServiceUpdateData, UserServiceUpdateErrors, UserServiceUpdateResponses } from './types.gen'; +import type { AuthServiceCreateData, AuthServiceCreateResponses, CommentServiceCreateData, CommentServiceCreateResponses, CommentServiceDeleteData, CommentServiceDeleteErrors, CommentServiceDeleteResponses, CommentServiceGetData, CommentServiceGetErrors, CommentServiceGetResponses, CommentServiceListData, CommentServiceListResponses, CommentServiceUpdateData, CommentServiceUpdateErrors, CommentServiceUpdateResponses, CourseServiceCreateData, CourseServiceCreateResponses, CourseServiceDeleteData, CourseServiceDeleteResponses, CourseServiceGetData, CourseServiceGetResponses, CourseServiceListData, CourseServiceListResponses, CourseServiceUpdateData, CourseServiceUpdateResponses, PostServiceCreateData, PostServiceCreateResponses, PostServiceDeleteData, PostServiceDeleteErrors, PostServiceDeleteResponses, PostServiceGetCommentsData, PostServiceGetCommentsResponses, PostServiceGetData, PostServiceGetErrors, PostServiceGetResponses, PostServiceListData, PostServiceListResponses, PostServiceUpdateData, PostServiceUpdateErrors, PostServiceUpdateResponses, TaskServiceCreateData, TaskServiceCreateResponses, TaskServiceDeleteData, TaskServiceDeleteErrors, TaskServiceDeleteResponses, TaskServiceGetData, TaskServiceGetErrors, TaskServiceGetResponses, TaskServiceListData, TaskServiceListResponses, TaskServiceUpdateData, TaskServiceUpdateErrors, TaskServiceUpdateResponses, UserServiceCreateData, UserServiceCreateResponses, UserServiceDeleteData, UserServiceDeleteErrors, UserServiceDeleteResponses, UserServiceGetCommentsData, UserServiceGetCommentsResponses, UserServiceGetData, UserServiceGetErrors, UserServiceGetPostsData, UserServiceGetPostsResponses, UserServiceGetResponses, UserServiceListData, UserServiceListResponses, UserServiceUpdateData, UserServiceUpdateErrors, UserServiceUpdateResponses } from './types.gen.ts'; export type RouteHandlers = { commentServiceList: RouteHandler<{ diff --git a/custom-server/src/generated/index.ts b/custom-server/src/generated/index.ts index a55b129..0751cc3 100644 --- a/custom-server/src/generated/index.ts +++ b/custom-server/src/generated/index.ts @@ -1,3 +1,3 @@ // This file is auto-generated by @hey-api/openapi-ts -export type { AuthData, AuthServiceCreateData, AuthServiceCreateResponse, AuthServiceCreateResponses, AuthToken, ClientOptions, Comment, Comments, CommentServiceCreateData, CommentServiceCreateResponse, CommentServiceCreateResponses, CommentServiceDeleteData, CommentServiceDeleteErrors, CommentServiceDeleteResponse, CommentServiceDeleteResponses, CommentServiceGetData, CommentServiceGetErrors, CommentServiceGetResponse, CommentServiceGetResponses, CommentServiceListData, CommentServiceListResponse, CommentServiceListResponses, CommentServiceUpdateData, CommentServiceUpdateErrors, CommentServiceUpdateResponse, CommentServiceUpdateResponses, Course, Courses, CourseServiceCreateData, CourseServiceCreateResponse, CourseServiceCreateResponses, CourseServiceDeleteData, CourseServiceDeleteResponse, CourseServiceDeleteResponses, CourseServiceGetData, CourseServiceGetResponse, CourseServiceGetResponses, CourseServiceListData, CourseServiceListResponse, CourseServiceListResponses, CourseServiceUpdateData, CourseServiceUpdateResponse, CourseServiceUpdateResponses, EditCommentDto, EditCourseDto, EditPostDto, EditTaskDto, EditUserDto, NewCommentDto, NewCourseDto, NewPostDto, NewTaskDto, NewUserDto, Post, Posts, PostServiceCreateData, PostServiceCreateResponse, PostServiceCreateResponses, PostServiceDeleteData, PostServiceDeleteErrors, PostServiceDeleteResponse, PostServiceDeleteResponses, PostServiceGetCommentsData, PostServiceGetCommentsResponse, PostServiceGetCommentsResponses, PostServiceGetData, PostServiceGetErrors, PostServiceGetResponse, PostServiceGetResponses, PostServiceListData, PostServiceListResponse, PostServiceListResponses, PostServiceUpdateData, PostServiceUpdateErrors, PostServiceUpdateResponse, PostServiceUpdateResponses, Status, Task, Tasks, TaskServiceCreateData, TaskServiceCreateResponse, TaskServiceCreateResponses, TaskServiceDeleteData, TaskServiceDeleteErrors, TaskServiceDeleteResponse, TaskServiceDeleteResponses, TaskServiceGetData, TaskServiceGetErrors, TaskServiceGetResponse, TaskServiceGetResponses, TaskServiceListData, TaskServiceListResponse, TaskServiceListResponses, TaskServiceUpdateData, TaskServiceUpdateErrors, TaskServiceUpdateResponse, TaskServiceUpdateResponses, User, Users, UserServiceCreateData, UserServiceCreateResponse, UserServiceCreateResponses, UserServiceDeleteData, UserServiceDeleteErrors, UserServiceDeleteResponse, UserServiceDeleteResponses, UserServiceGetCommentsData, UserServiceGetCommentsResponse, UserServiceGetCommentsResponses, UserServiceGetData, UserServiceGetErrors, UserServiceGetPostsData, UserServiceGetPostsResponse, UserServiceGetPostsResponses, UserServiceGetResponse, UserServiceGetResponses, UserServiceListData, UserServiceListResponse, UserServiceListResponses, UserServiceUpdateData, UserServiceUpdateErrors, UserServiceUpdateResponse, UserServiceUpdateResponses, Versions } from './types.gen'; +export type { AuthData, AuthServiceCreateData, AuthServiceCreateResponse, AuthServiceCreateResponses, AuthToken, ClientOptions, Comment, Comments, CommentServiceCreateData, CommentServiceCreateResponse, CommentServiceCreateResponses, CommentServiceDeleteData, CommentServiceDeleteErrors, CommentServiceDeleteResponse, CommentServiceDeleteResponses, CommentServiceGetData, CommentServiceGetErrors, CommentServiceGetResponse, CommentServiceGetResponses, CommentServiceListData, CommentServiceListResponse, CommentServiceListResponses, CommentServiceUpdateData, CommentServiceUpdateErrors, CommentServiceUpdateResponse, CommentServiceUpdateResponses, Course, Courses, CourseServiceCreateData, CourseServiceCreateResponse, CourseServiceCreateResponses, CourseServiceDeleteData, CourseServiceDeleteResponse, CourseServiceDeleteResponses, CourseServiceGetData, CourseServiceGetResponse, CourseServiceGetResponses, CourseServiceListData, CourseServiceListResponse, CourseServiceListResponses, CourseServiceUpdateData, CourseServiceUpdateResponse, CourseServiceUpdateResponses, EditCommentDto, EditCourseDto, EditPostDto, EditTaskDto, EditUserDto, NewCommentDto, NewCourseDto, NewPostDto, NewTaskDto, NewUserDto, Post, Posts, PostServiceCreateData, PostServiceCreateResponse, PostServiceCreateResponses, PostServiceDeleteData, PostServiceDeleteErrors, PostServiceDeleteResponse, PostServiceDeleteResponses, PostServiceGetCommentsData, PostServiceGetCommentsResponse, PostServiceGetCommentsResponses, PostServiceGetData, PostServiceGetErrors, PostServiceGetResponse, PostServiceGetResponses, PostServiceListData, PostServiceListResponse, PostServiceListResponses, PostServiceUpdateData, PostServiceUpdateErrors, PostServiceUpdateResponse, PostServiceUpdateResponses, Status, Task, Tasks, TaskServiceCreateData, TaskServiceCreateResponse, TaskServiceCreateResponses, TaskServiceDeleteData, TaskServiceDeleteErrors, TaskServiceDeleteResponse, TaskServiceDeleteResponses, TaskServiceGetData, TaskServiceGetErrors, TaskServiceGetResponse, TaskServiceGetResponses, TaskServiceListData, TaskServiceListResponse, TaskServiceListResponses, TaskServiceUpdateData, TaskServiceUpdateErrors, TaskServiceUpdateResponse, TaskServiceUpdateResponses, User, Users, UserServiceCreateData, UserServiceCreateResponse, UserServiceCreateResponses, UserServiceDeleteData, UserServiceDeleteErrors, UserServiceDeleteResponse, UserServiceDeleteResponses, UserServiceGetCommentsData, UserServiceGetCommentsResponse, UserServiceGetCommentsResponses, UserServiceGetData, UserServiceGetErrors, UserServiceGetPostsData, UserServiceGetPostsResponse, UserServiceGetPostsResponses, UserServiceGetResponse, UserServiceGetResponses, UserServiceListData, UserServiceListResponse, UserServiceListResponses, UserServiceUpdateData, UserServiceUpdateErrors, UserServiceUpdateResponse, UserServiceUpdateResponses, Versions } from './types.gen.ts'; diff --git a/custom-server/src/handlers.ts b/custom-server/src/handlers.ts index 973ac7e..d83d655 100644 --- a/custom-server/src/handlers.ts +++ b/custom-server/src/handlers.ts @@ -9,17 +9,20 @@ // 401, 404 и 405: всё это описано в спецификации и делается до обработчика. // Остаётся выборка из набора данных, то есть то, ради чего маршруты и забрали // у мока: skip, limit, select и отбор по идентификатору в пути. +import type { FastifyReply, FastifyRequest } from 'fastify'; + import type { RouteHandlers } from './generated/fastify.gen.ts'; -import { findById, nextId, page, parseSelect, project } from './collections.js'; +import { findById, nextId, page, parseSelect, project } from './collections.ts'; +import type { Identified } from './collections.ts'; // Сборка новой задачи берётся из того же модуля, что и у JSON-RPC: урок kinds // сравнивает два стиля и опирается на то, что они делают одно и то же. -import { build as buildTask } from './tasks-store.js'; -import comments from './data/comments.js'; -import courses from './data/courses.js'; -import posts from './data/posts.js'; -import tasks from './data/tasks.js'; -import users from './data/users.js'; +import { build as buildTask } from './tasks-store.ts'; +import comments from './data/comments.ts'; +import courses from './data/courses.ts'; +import posts from './data/posts.ts'; +import tasks from './data/tasks.ts'; +import users from './data/users.ts'; // Автор создаваемой записи не приходит в теле: сервер узнаёт его по токену. // Урок authentication обращает на это внимание отдельно, показывая, что в @@ -30,7 +33,22 @@ const TOKEN_USER_ID = 1; // в уроке authentication. const TOKEN = 'r4AR4Fo0j29s9mFk4IUVA2rGTQmIrHWlioifaJLSQQYHbTXHxtSLFUVp8PANrRoAb7fgkSsbN7lt4a86pcJ07ivUpxBLyyCHaY4Pp9I7hRPphCHM7xpZ1om1'; -const notFound = (reply, id) => reply +// Типы запроса сгенерированы по спецификации, но их Reply это карта статусов, а +// не тело ответа, и под reply.send она не сходится. Поэтому сигнатуры здесь +// свободные, а ключи объекта проверяет satisfies ниже: имя операции в +// спецификации и ключ тут расходятся ошибкой типов, а не падением на старте. +type Request = FastifyRequest<{ + Params: Record; + Querystring: { skip?: number; limit?: number; select?: string | string[] }; + Body: any; +}>; +type Handler = (req: Request, reply: FastifyReply) => unknown; + +interface Authored extends Identified { + authorId: number; +} + +const notFound = (reply: FastifyReply, id: string | number) => reply .code(404) .send({ code: 404, message: `Записи с идентификатором ${id} нет` }); @@ -38,12 +56,17 @@ const notFound = (reply, id) => reply // от множества студентов, и мутации сделали бы уроки невоспроизводимыми: // самостоятельная описывает один набор, а следующий студент получил бы другой. // Поэтому create, update и delete только сообщают, что произошло бы. -const collection = (items, envelope, build) => ({ +const collection = ( + items: T[], + envelope: string, + build: (dto: any) => unknown, +): Record<'list' | 'get' | 'create' | 'update' | 'remove', Handler> => ({ list: (req, reply) => reply.send(page({ items, envelope, - skip: req.query.skip, - limit: req.query.limit, + // Значения по умолчанию подставляет валидатор по спецификации. + skip: req.query.skip as number, + limit: req.query.limit as number, fields: parseSelect(req.query.select), })), @@ -58,7 +81,7 @@ const collection = (items, envelope, build) => ({ update: (req, reply) => { const found = findById(items, req.params.id); if (!found) return notFound(reply, req.params.id); - return reply.send({ ...found, ...req.body, id: found.id }); + return reply.send({ ...(found as object), ...(req.body as object), id: found.id }); }, remove: (req, reply) => { @@ -70,23 +93,29 @@ const collection = (items, envelope, build) => ({ // Вложенный ресурс отбирает записи по родителю: именно этого не умел мок, // из-за чего /users/1/posts отдавал тот же список, что /posts. -const nested = (parents, children, envelope, foreignKey) => (req, reply) => { +const nested = ( + parents: Identified[], + children: Authored[], + envelope: string, + foreignKey: string, +): Handler => (req, reply) => { // Имя параметра пути берётся из спецификации: у постов автора это authorId, // у комментариев поста postId, а не общий id. const parentId = req.params[foreignKey]; const parent = findById(parents, parentId); if (!parent) return notFound(reply, parentId); - const own = children.filter((child) => child[foreignKey] === parent.id); + const own = children.filter((child) => (child as any)[foreignKey] === parent.id); return reply.send(page({ items: own, envelope, - skip: req.query.skip, - limit: req.query.limit, + // Значения по умолчанию подставляет валидатор по спецификации. + skip: req.query.skip as number, + limit: req.query.limit as number, fields: parseSelect(req.query.select), })); }; -const usersRoutes = collection(users, 'users', (dto) => ({ +const usersRoutes = collection(users, 'users', (dto: any) => ({ id: nextId(users), email: dto.email, firstName: dto.firstName, @@ -94,14 +123,14 @@ const usersRoutes = collection(users, 'users', (dto) => ({ // Пароля в ответе нет, его нет и в модели User. })); -const postsRoutes = collection(posts, 'posts', (dto) => ({ +const postsRoutes = collection(posts, 'posts', (dto: any) => ({ id: nextId(posts), authorId: TOKEN_USER_ID, title: dto.title, body: dto.body, })); -const commentsRoutes = collection(comments, 'comments', (dto) => ({ +const commentsRoutes = collection(comments, 'comments', (dto: any) => ({ id: nextId(comments), authorId: TOKEN_USER_ID, postId: Number(dto.postId), @@ -110,13 +139,13 @@ const commentsRoutes = collection(comments, 'comments', (dto) => ({ const tasksRoutes = collection(tasks, 'tasks', buildTask); -const coursesRoutes = collection(courses, 'courses', (dto) => ({ +const coursesRoutes = collection(courses, 'courses', (dto: any) => ({ id: nextId(courses), title: dto.title, description: dto.description, })); -const handlers: RouteHandlers = { +const handlers = { userServiceList: usersRoutes.list, userServiceGet: usersRoutes.get, userServiceCreate: usersRoutes.create, @@ -151,6 +180,6 @@ const handlers: RouteHandlers = { courseServiceDelete: coursesRoutes.remove, authServiceCreate: (req, reply) => reply.send({ token: TOKEN }), -}; +} satisfies Record; export default handlers; diff --git a/custom-server/src/index.js b/custom-server/src/index.ts similarity index 80% rename from custom-server/src/index.js rename to custom-server/src/index.ts index b159b3f..f74d80c 100644 --- a/custom-server/src/index.js +++ b/custom-server/src/index.ts @@ -1,20 +1,29 @@ import path from 'node:path'; +import type { FastifyInstance, FastifyPluginAsync, FastifyServerOptions } from 'fastify'; import fp from 'fastify-plugin'; import fastifySwagger from '@fastify/swagger'; import swaggerUI from '@fastify/swagger-ui'; import fastifyStatic from '@fastify/static'; import formbody from '@fastify/formbody'; -import allow from 'fastify-allow'; +import allowPlugin from 'fastify-allow'; import fastifyCookie from '@fastify/cookie'; -import appConfig from '../../app.config.json' with {type: 'json'} -import setUpRpc from './rpc.js'; -import setUpBodyExamples from './body-examples.js'; +import rawConfig from '../../app.config.json' with { type: 'json' }; +import setUpRpc from './rpc.ts'; +import setUpBodyExamples from './body-examples.ts'; import setUpSpec from './openapi-spec.ts'; +// title в app.config.json нет, и документация всегда отдавалась без заголовка. +// Тип это допускает, чтобы не менять ответ. +const appConfig: { docRoute: string; apps: string[]; title?: string } = rawConfig; + +// fastify-allow отдаёт CommonJS-экспорт, и под nodenext его типы не сходятся с +// сигнатурой register, хотя по сути это обычный плагин. +const allow = allowPlugin as unknown as FastifyPluginAsync; + const { dirname } = import.meta; -const setUpStaticAssets = (app) => { +const setUpStaticAssets = (app: FastifyInstance) => { const pathPublic = path.join(dirname, '../assets'); app.register(fastifyStatic, { root: pathPublic, @@ -22,23 +31,21 @@ const setUpStaticAssets = (app) => { }); }; -const setupDocs = async (app) => { - const getPromises = (instance) => appConfig.apps.map(async (name) => { +const setupDocs = async (app: FastifyInstance) => { + const getPromises = (instance: FastifyInstance) => appConfig.apps.map(async (name) => { const openapiFilePath = path.join(dirname, '../../tsp-output/', name, '/@typespec/openapi3/openapi.1.0.yaml'); return await instance.register(async (innerInstance) => { await instance.register(fastifySwagger, { mode: 'static', - title: appConfig.title, - exposeRoute: true, specification: { path: openapiFilePath, + baseDir: path.dirname(openapiFilePath), }, // routePrefix: `${name}-${appConfig.docRoute}`, }); await instance.register(swaggerUI, { routePrefix: `${name}-${appConfig.docRoute}`, - title: appConfig.title, staticCSP: false, transformSpecificationClone: true, theme: { @@ -48,7 +55,9 @@ const setupDocs = async (app) => { }); }) - await app.register(fp((instance) => Promise.all(getPromises(instance)))); + await app.register(fp(async (instance) => { + await Promise.all(getPromises(instance)); + })); }; // Опции самого экземпляра fastify: fastify-cli подхватывает этот экспорт, но @@ -61,20 +70,22 @@ const setupDocs = async (app) => { // от отрицательного числа отдаёт хвост списка вместо ошибки. const UINT16_MAX = 65535; -export const options = { +export const options: FastifyServerOptions = { ajv: { customOptions: { formats: { uint16: { type: 'number', - validate: (value) => Number.isInteger(value) && value >= 0 && value <= UINT16_MAX, + validate: (value: number) => Number.isInteger(value) && value >= 0 && value <= UINT16_MAX, }, }, }, }, }; -export default async (app, _options) => { +// Второй параметр нужен не ради значения: fastify-cli проверяет длину функции и +// отвергает async-плагин с одним аргументом, приложение тогда не стартует. +export default async (app: FastifyInstance, _options: unknown) => { // Метод, которого у адреса нет, отвечает 405 и заголовком Allow, а не 404. // Самостоятельная урока kinds просит выполнить три заведомо неудачных // запроса и записать три *разных* кода, и DELETE /tasks один из них. diff --git a/custom-server/src/openapi-spec.ts b/custom-server/src/openapi-spec.ts index f7fbb65..8611180 100644 --- a/custom-server/src/openapi-spec.ts +++ b/custom-server/src/openapi-spec.ts @@ -17,6 +17,7 @@ // // Остаётся здесь только то, чего в спецификации выразить нельзя: // 405, отображение 400 в 422 и снятие схемы ответа у операций с select. +import type { FastifyError, FastifyInstance, FastifyRequest } from 'fastify'; import glue from 'fastify-openapi-glue'; import serviceHandlers from './handlers.ts'; @@ -33,38 +34,39 @@ import serviceHandlers from './handlers.ts'; // у задач курса Postman. Basic встречается только там, и снимать его нельзя: // урок про авторизацию в Postman разбирает именно этот способ. const securityHandlers = { - BearerAuth: async (req) => { + BearerAuth: async (req: FastifyRequest) => { if (!/^Bearer\s+\S/i.test(req.headers.authorization ?? '')) { throw new Error('нужен заголовок Authorization с Bearer-токеном'); } }, - ApiKeyAuth: async (req) => { + ApiKeyAuth: async (req: FastifyRequest) => { if (!req.headers['x-api-key']) { throw new Error('нужен заголовок X-API-KEY'); } }, - BasicAuth: async (req) => { + BasicAuth: async (req: FastifyRequest) => { if (!/^Basic\s+\S/i.test(req.headers.authorization ?? '')) { throw new Error('нужен заголовок Authorization со схемой Basic'); } }, }; -export default async (app, { name }) => { +export default async (app: FastifyInstance, { name }: { name: string }) => { // Схема ответа снимается там, где у операции есть select. OpenAPI не умеет // сказать «набор полей ответа зависит от параметра запроса», поэтому // сериализатор падает на записи без lastName, хотя ответ верный. Валидация // запроса при этом остаётся, снимается только схема ответа. app.addHook('onRoute', (route) => { - const query = route.schema?.querystring?.properties ?? {}; - if ('select' in query && route.schema?.response) { - delete route.schema.response; + const schema = route.schema as { querystring?: { properties?: object }; response?: unknown } | undefined; + const query = schema?.querystring?.properties ?? {}; + if ('select' in query && schema?.response) { + delete schema.response; } }); // Валидация по схеме отвечает 400, а уроки курса учат на 422: урок crud // прямо называет 422 кодом ошибки валидации. - app.setErrorHandler((error, req, res) => { + app.setErrorHandler((error: FastifyError, req, res) => { if (error.code === 'FST_ERR_VALIDATION') { return res.code(422).send({ statusCode: 422, diff --git a/custom-server/src/rpc.js b/custom-server/src/rpc.ts similarity index 74% rename from custom-server/src/rpc.js rename to custom-server/src/rpc.ts index fd47c53..3b51721 100644 --- a/custom-server/src/rpc.js +++ b/custom-server/src/rpc.ts @@ -6,9 +6,12 @@ // собираются REST-маршруты. Урок kinds сравнивает два стиля и опирается на то, // что задача с одним номером в обоих одна и та же. +import type { FastifyInstance } from 'fastify'; + import { build, find, list, parseRange, validate, -} from './tasks-store.js'; +} from './tasks-store.ts'; +import type { TaskDto } from './tasks-store.ts'; // Negative codes are the protocol level ones defined by the JSON-RPC spec. // Application errors like "task not found" get positive codes chosen by the API itself. @@ -18,9 +21,19 @@ const errors = { invalidParams: { code: -32602, message: 'Invalid params' }, }; -const notFound = (id) => ({ error: { code: 1, message: `Task with id ${id} not found` } }); +// Параметры приходят из тела запроса как есть, проверяет их только код ниже. +type Params = TaskDto & { id?: number; skip?: number; limit?: number }; + +interface RpcCall { + jsonrpc?: string; + method?: unknown; + params?: Params; + id?: unknown; +} + +const notFound = (id: unknown) => ({ error: { code: 1, message: `Task with id ${id} not found` } }); -const methods = { +const methods: Record object> = { 'tasks.list': (params = {}) => { const range = parseRange(params); if (range === null) { @@ -47,7 +60,7 @@ const methods = { }, }; -const handleCall = (call) => { +const handleCall = (call: RpcCall | null) => { const id = call?.id ?? null; if (call?.jsonrpc !== '2.0' || typeof call.method !== 'string') { @@ -62,9 +75,9 @@ const handleCall = (call) => { return { jsonrpc: '2.0', ...method(call.params), id }; }; -export default (app) => { +export default (app: FastifyInstance) => { app.post('/http-api/rpc', (req, res) => { - const body = Array.isArray(req.body) ? req.body.map(handleCall) : handleCall(req.body); + const body = Array.isArray(req.body) ? req.body.map(handleCall) : handleCall(req.body as RpcCall); res.send(body); }); }; diff --git a/custom-server/src/tasks-store.js b/custom-server/src/tasks-store.ts similarity index 64% rename from custom-server/src/tasks-store.js rename to custom-server/src/tasks-store.ts index 7354375..b2a5a1b 100644 --- a/custom-server/src/tasks-store.js +++ b/custom-server/src/tasks-store.ts @@ -7,32 +7,43 @@ import { findById, nextId, page, parseRange, parseSelect, validateFields, -} from './collections.js'; -import tasks from './data/tasks.js'; +} from './collections.ts'; +import tasks from './data/tasks.ts'; const STATUSES = ['Backlog', 'Ready', 'In Progress', 'Done', 'Archived']; export { parseRange }; +export interface TaskDto { + title?: string; + description?: string; + status?: string; +} + export const items = () => tasks; -export const validate = (dto = {}, options = {}) => { - const problems = validateFields(dto, { required: ['title', 'description'], ...options }); +export const validate = ( + dto: TaskDto = {}, + options: { partial?: boolean } = {}, +): string[] => { + const problems = validateFields(dto as Record, { required: ['title', 'description'], ...options }); if (dto.status !== undefined && !STATUSES.includes(dto.status)) { problems.push(`status должен быть одним из: ${STATUSES.join(', ')}`); } return problems; }; -export const build = (dto) => ({ +export const build = (dto: TaskDto) => ({ id: nextId(tasks), title: dto.title, description: dto.description, status: dto.status ?? 'Backlog', }); -export const list = ({ skip, limit, select } = {}) => page({ +export const list = ( + { skip = 0, limit = 30, select }: { skip?: number; limit?: number; select?: string | string[] } = {}, +) => page({ items: tasks, envelope: 'tasks', skip, limit, fields: parseSelect(select), }); -export const find = (id) => findById(tasks, id); +export const find = (id: string | number | undefined) => findById(tasks, id); diff --git a/openapi-ts.config.ts b/openapi-ts.config.ts index a3505ad..3e04909 100644 --- a/openapi-ts.config.ts +++ b/openapi-ts.config.ts @@ -12,6 +12,6 @@ import { defineConfig } from '@hey-api/openapi-ts'; // обработчик не нашёлся бы. export default defineConfig({ input: './tsp-output/http-api/@typespec/openapi3/openapi.1.0.yaml', - output: { path: './custom-server/src/generated' }, + output: { path: './custom-server/src/generated', importFileExtension: '.ts' }, plugins: ['fastify'], }); diff --git a/package.json b/package.json index 57a3563..5e98394 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,11 @@ "version": "0.1.0", "type": "module", "scripts": { - "start": "fastify start --options -p 4010 -a 0.0.0.0 custom-server/src/index.js" + "start": "fastify start --options -p 4010 -a 0.0.0.0 custom-server/src/index.ts" }, "devDependencies": { "@hey-api/openapi-ts": "^0.99.0", + "@types/node": "^26.6.5", "npm-check-updates": "^23.1.1", "oxfmt": "^0.73.0", "oxlint": "^1.88.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e7f9f8f..dac07cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,22 +188,22 @@ importers: version: 6.1.2 '@typespec/compiler': specifier: ^1.17.0 - version: 1.17.0 + version: 1.17.0(@types/node@26.6.5) '@typespec/http': specifier: ^1.17.0 - version: 1.17.0(@typespec/compiler@1.17.0) + version: 1.17.0(@typespec/compiler@1.17.0(@types/node@26.6.5)) '@typespec/openapi': specifier: ^1.17.0 - version: 1.17.0(@typespec/compiler@1.17.0)(@typespec/http@1.17.0(@typespec/compiler@1.17.0)) + version: 1.17.0(@typespec/compiler@1.17.0(@types/node@26.6.5))(@typespec/http@1.17.0(@typespec/compiler@1.17.0(@types/node@26.6.5))) '@typespec/openapi3': specifier: ^1.17.0 - version: 1.17.0(@typespec/compiler@1.17.0)(@typespec/http@1.17.0(@typespec/compiler@1.17.0))(@typespec/openapi@1.17.0(@typespec/compiler@1.17.0)(@typespec/http@1.17.0(@typespec/compiler@1.17.0)))(@typespec/versioning@0.87.0(@typespec/compiler@1.17.0)) + version: 1.17.0(@typespec/compiler@1.17.0(@types/node@26.6.5))(@typespec/http@1.17.0(@typespec/compiler@1.17.0(@types/node@26.6.5)))(@typespec/openapi@1.17.0(@typespec/compiler@1.17.0(@types/node@26.6.5))(@typespec/http@1.17.0(@typespec/compiler@1.17.0(@types/node@26.6.5))))(@typespec/versioning@0.87.0(@typespec/compiler@1.17.0(@types/node@26.6.5))) '@typespec/rest': specifier: ^0.87.0 - version: 0.87.0(@typespec/compiler@1.17.0)(@typespec/http@1.17.0(@typespec/compiler@1.17.0)) + version: 0.87.0(@typespec/compiler@1.17.0(@types/node@26.6.5))(@typespec/http@1.17.0(@typespec/compiler@1.17.0(@types/node@26.6.5))) '@typespec/versioning': specifier: ^0.87.0 - version: 0.87.0(@typespec/compiler@1.17.0) + version: 0.87.0(@typespec/compiler@1.17.0(@types/node@26.6.5)) fastify: specifier: ^5.12.7 version: 5.12.7 @@ -223,6 +223,9 @@ importers: '@hey-api/openapi-ts': specifier: ^0.99.0 version: 0.99.0 + '@types/node': + specifier: ^26.6.5 + version: 26.6.5 npm-check-updates: specifier: ^23.1.1 version: 23.1.1 @@ -237,7 +240,7 @@ importers: version: 7.0.2 vitest: specifier: ^5.0.4 - version: 5.0.4(vite@8.3.4(jiti@2.7.0)(yaml@2.9.1)) + version: 5.0.4(@types/node@26.6.5)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1)) packages: @@ -823,6 +826,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@26.6.5': + resolution: {integrity: sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==} + '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} engines: {node: '>=16.20.0'} @@ -1885,6 +1891,9 @@ packages: engines: {node: '>=16.20.0'} hasBin: true + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + undici@7.24.4: resolution: {integrity: sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==} engines: {node: '>=20.18.1'} @@ -2203,43 +2212,55 @@ snapshots: '@inquirer/ansi@2.0.8': {} - '@inquirer/checkbox@5.2.5': + '@inquirer/checkbox@5.2.5(@types/node@26.6.5)': dependencies: '@inquirer/ansi': 2.0.8 - '@inquirer/core': 12.0.3 + '@inquirer/core': 12.0.3(@types/node@26.6.5) '@inquirer/figures': 2.0.9 - '@inquirer/type': 4.1.1 + '@inquirer/type': 4.1.1(@types/node@26.6.5) + optionalDependencies: + '@types/node': 26.6.5 - '@inquirer/confirm@6.3.2': + '@inquirer/confirm@6.3.2(@types/node@26.6.5)': dependencies: - '@inquirer/core': 12.0.3 - '@inquirer/type': 4.1.1 + '@inquirer/core': 12.0.3(@types/node@26.6.5) + '@inquirer/type': 4.1.1(@types/node@26.6.5) + optionalDependencies: + '@types/node': 26.6.5 - '@inquirer/core@12.0.3': + '@inquirer/core@12.0.3(@types/node@26.6.5)': dependencies: '@inquirer/ansi': 2.0.8 '@inquirer/figures': 2.0.9 - '@inquirer/type': 4.1.1 + '@inquirer/type': 4.1.1(@types/node@26.6.5) cli-width: 4.1.0 fast-wrap-ansi: 0.2.2 mute-stream: 3.0.0 signal-exit: 4.1.0 + optionalDependencies: + '@types/node': 26.6.5 '@inquirer/figures@2.0.9': {} - '@inquirer/input@5.1.6': + '@inquirer/input@5.1.6(@types/node@26.6.5)': dependencies: - '@inquirer/core': 12.0.3 - '@inquirer/type': 4.1.1 + '@inquirer/core': 12.0.3(@types/node@26.6.5) + '@inquirer/type': 4.1.1(@types/node@26.6.5) + optionalDependencies: + '@types/node': 26.6.5 - '@inquirer/select@5.2.5': + '@inquirer/select@5.2.5(@types/node@26.6.5)': dependencies: '@inquirer/ansi': 2.0.8 - '@inquirer/core': 12.0.3 + '@inquirer/core': 12.0.3(@types/node@26.6.5) '@inquirer/figures': 2.0.9 - '@inquirer/type': 4.1.1 + '@inquirer/type': 4.1.1(@types/node@26.6.5) + optionalDependencies: + '@types/node': 26.6.5 - '@inquirer/type@4.1.1': {} + '@inquirer/type@4.1.1(@types/node@26.6.5)': + optionalDependencies: + '@types/node': 26.6.5 '@isaacs/fs-minipass@4.0.1': dependencies: @@ -2510,6 +2531,10 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/node@26.6.5': + dependencies: + undici-types: 8.9.0 + '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -2574,17 +2599,17 @@ snapshots: dependencies: '@typescript/old': typescript@6.0.3 - '@typespec/asset-emitter@0.79.3(@typespec/compiler@1.17.0)': + '@typespec/asset-emitter@0.79.3(@typespec/compiler@1.17.0(@types/node@26.6.5))': dependencies: - '@typespec/compiler': 1.17.0 + '@typespec/compiler': 1.17.0(@types/node@26.6.5) - '@typespec/compiler@1.17.0': + '@typespec/compiler@1.17.0(@types/node@26.6.5)': dependencies: '@babel/code-frame': 8.0.6 - '@inquirer/checkbox': 5.2.5 - '@inquirer/confirm': 6.3.2 - '@inquirer/input': 5.1.6 - '@inquirer/select': 5.2.5 + '@inquirer/checkbox': 5.2.5(@types/node@26.6.5) + '@inquirer/confirm': 6.3.2(@types/node@26.6.5) + '@inquirer/input': 5.1.6(@types/node@26.6.5) + '@inquirer/select': 5.2.5(@types/node@26.6.5) ajv: 8.20.0 change-case: 5.4.4 env-paths: 4.0.0 @@ -2602,46 +2627,46 @@ snapshots: transitivePeerDependencies: - '@types/node' - '@typespec/http@1.17.0(@typespec/compiler@1.17.0)': + '@typespec/http@1.17.0(@typespec/compiler@1.17.0(@types/node@26.6.5))': dependencies: - '@typespec/compiler': 1.17.0 + '@typespec/compiler': 1.17.0(@types/node@26.6.5) - '@typespec/openapi3@1.17.0(@typespec/compiler@1.17.0)(@typespec/http@1.17.0(@typespec/compiler@1.17.0))(@typespec/openapi@1.17.0(@typespec/compiler@1.17.0)(@typespec/http@1.17.0(@typespec/compiler@1.17.0)))(@typespec/versioning@0.87.0(@typespec/compiler@1.17.0))': + '@typespec/openapi3@1.17.0(@typespec/compiler@1.17.0(@types/node@26.6.5))(@typespec/http@1.17.0(@typespec/compiler@1.17.0(@types/node@26.6.5)))(@typespec/openapi@1.17.0(@typespec/compiler@1.17.0(@types/node@26.6.5))(@typespec/http@1.17.0(@typespec/compiler@1.17.0(@types/node@26.6.5))))(@typespec/versioning@0.87.0(@typespec/compiler@1.17.0(@types/node@26.6.5)))': dependencies: '@scalar/json-magic': 0.13.5 '@scalar/openapi-parser': 0.29.13 '@scalar/openapi-types': 0.9.7 '@scalar/types': 0.19.0 - '@typespec/asset-emitter': 0.79.3(@typespec/compiler@1.17.0) - '@typespec/compiler': 1.17.0 - '@typespec/http': 1.17.0(@typespec/compiler@1.17.0) - '@typespec/openapi': 1.17.0(@typespec/compiler@1.17.0)(@typespec/http@1.17.0(@typespec/compiler@1.17.0)) + '@typespec/asset-emitter': 0.79.3(@typespec/compiler@1.17.0(@types/node@26.6.5)) + '@typespec/compiler': 1.17.0(@types/node@26.6.5) + '@typespec/http': 1.17.0(@typespec/compiler@1.17.0(@types/node@26.6.5)) + '@typespec/openapi': 1.17.0(@typespec/compiler@1.17.0(@types/node@26.6.5))(@typespec/http@1.17.0(@typespec/compiler@1.17.0(@types/node@26.6.5))) yaml: 2.9.1 optionalDependencies: - '@typespec/versioning': 0.87.0(@typespec/compiler@1.17.0) + '@typespec/versioning': 0.87.0(@typespec/compiler@1.17.0(@types/node@26.6.5)) - '@typespec/openapi@1.17.0(@typespec/compiler@1.17.0)(@typespec/http@1.17.0(@typespec/compiler@1.17.0))': + '@typespec/openapi@1.17.0(@typespec/compiler@1.17.0(@types/node@26.6.5))(@typespec/http@1.17.0(@typespec/compiler@1.17.0(@types/node@26.6.5)))': dependencies: - '@typespec/compiler': 1.17.0 - '@typespec/http': 1.17.0(@typespec/compiler@1.17.0) + '@typespec/compiler': 1.17.0(@types/node@26.6.5) + '@typespec/http': 1.17.0(@typespec/compiler@1.17.0(@types/node@26.6.5)) - '@typespec/rest@0.87.0(@typespec/compiler@1.17.0)(@typespec/http@1.17.0(@typespec/compiler@1.17.0))': + '@typespec/rest@0.87.0(@typespec/compiler@1.17.0(@types/node@26.6.5))(@typespec/http@1.17.0(@typespec/compiler@1.17.0(@types/node@26.6.5)))': dependencies: - '@typespec/compiler': 1.17.0 - '@typespec/http': 1.17.0(@typespec/compiler@1.17.0) + '@typespec/compiler': 1.17.0(@types/node@26.6.5) + '@typespec/http': 1.17.0(@typespec/compiler@1.17.0(@types/node@26.6.5)) - '@typespec/versioning@0.87.0(@typespec/compiler@1.17.0)': + '@typespec/versioning@0.87.0(@typespec/compiler@1.17.0(@types/node@26.6.5))': dependencies: - '@typespec/compiler': 1.17.0 + '@typespec/compiler': 1.17.0(@types/node@26.6.5) - '@vitest/mocker@5.0.4(vite@8.3.4(jiti@2.7.0)(yaml@2.9.1))': + '@vitest/mocker@5.0.4(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))': dependencies: '@jridgewell/trace-mapping': 0.3.31 '@vitest/spy': 5.0.4 estree-walker: 3.0.3 magic-string: 1.4.3 optionalDependencies: - vite: 8.3.4(jiti@2.7.0)(yaml@2.9.1) + vite: 8.3.4(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1) '@vitest/spy@5.0.4': {} @@ -3447,13 +3472,15 @@ snapshots: '@typescript/typescript-win32-arm64': 7.0.2 '@typescript/typescript-win32-x64': 7.0.2 + undici-types@8.9.0: {} + undici@7.24.4: {} undici@7.29.1: {} util-deprecate@1.0.2: {} - vite@8.3.4(jiti@2.7.0)(yaml@2.9.1): + vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -3461,14 +3488,15 @@ snapshots: rolldown: 1.2.13 tinyglobby: 0.2.17 optionalDependencies: + '@types/node': 26.6.5 fsevents: 2.3.3 jiti: 2.7.0 yaml: 2.9.1 - vitest@5.0.4(vite@8.3.4(jiti@2.7.0)(yaml@2.9.1)): + vitest@5.0.4(@types/node@26.6.5)(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1)): dependencies: '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.4(vite@8.3.4(jiti@2.7.0)(yaml@2.9.1)) + '@vitest/mocker': 5.0.4(vite@8.3.4(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1)) chai: 6.3.0 es-module-lexer: 2.3.2 expect-type: 1.4.0 @@ -3479,8 +3507,10 @@ snapshots: tinybench: 6.2.2 tinyexec: 1.3.1 tinyglobby: 0.2.17 - vite: 8.3.4(jiti@2.7.0)(yaml@2.9.1) + vite: 8.3.4(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1) why-is-node-running: 3.2.1 + optionalDependencies: + '@types/node': 26.6.5 transitivePeerDependencies: - msw diff --git a/test/app.test.ts b/test/app.test.ts new file mode 100644 index 0000000..a064f59 --- /dev/null +++ b/test/app.test.ts @@ -0,0 +1,77 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { FastifyInstance } from 'fastify'; + +import appConfig from '../app.config.json' with { type: 'json' }; +import plugin from '../custom-server/src/index.ts'; +import { buildApp, createClient } from './helpers.ts'; + +let app: FastifyInstance; +let client: ReturnType; + +beforeAll(async () => { + app = await buildApp(); + client = createClient(app); +}); + +afterAll(() => app.close()); + +describe('контракт с fastify-cli', () => { + // fastify-cli отвергает async-плагин с одним аргументом, и приложение тогда не + // стартует вовсе. + it('плагин принимает два аргумента', () => { + expect(plugin.length).toBe(2); + }); +}); + +describe('документация и статика', () => { + it.each(appConfig.apps)('/%s-openapi отдаёт swagger-ui', async (name) => { + const reply = await client.send(`/${name}-${appConfig.docRoute}`); + expect(reply.status).toBe(200); + expect(String(reply.headers['content-type'])).toContain('text/html'); + }); + + it('главная страница отдаётся', async () => { + expect((await client.send('/')).status).toBe(200); + }); + + it('несуществующий адрес → 404 с кодом в теле', async () => { + const reply = await client.send('/nosuch'); + expect(reply.status).toBe(404); + expect(reply.body.code).toBe(404); + }); + + // В текстах курсов адрес пишется без слеша. + it('трейлинг-слеш у коллекции → 404', async () => { + expect((await client.send('/postman/users/')).status).toBe(404); + }); +}); + +describe('протокольные примеры', () => { + it('/http-protocol/example отдаёт Done! и куки', async () => { + const reply = await client.send('/http-protocol/example'); + expect(reply.status).toBe(200); + expect(reply.body).toBe('Done!'); + expect(reply.headers['set-cookie']).toHaveLength(2); + }); + + it('/http-protocol/removed → 301', async () => { + const reply = await client.send('/http-protocol/removed'); + expect(reply.status).toBe(301); + expect(reply.headers.location).toBe('/http-protocol/example'); + }); + + it('/postman/cookie ставит куку', async () => { + const reply = await client.send('/postman/cookie'); + expect(String(reply.headers['set-cookie'])).toContain('myCookie=cookieValue'); + }); + + it('/http-api/echo отражает тело', async () => { + const reply = await client.json('/http-api/echo', 'POST', { a: 1 }); + expect(reply.body).toEqual({ a: 1 }); + }); + + it('/http-protocol/stream отдаёт пять чанков', async () => { + const reply = await client.send('/http-protocol/stream'); + expect(reply.body).toBe('Chunk 1\nChunk 2\nChunk 3\nChunk 4\nChunk 5\n'); + }, 10000); +}); diff --git a/test/collections.test.ts b/test/collections.test.ts index 34fbd3d..0c35241 100644 --- a/test/collections.test.ts +++ b/test/collections.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { page, parseRange, parseSelect, project, -} from '../custom-server/src/collections.js'; +} from '../custom-server/src/collections.ts'; describe('parseSelect', () => { it('пустое значение означает «все поля»', () => { diff --git a/test/helpers.ts b/test/helpers.ts index 1dddf3d..7ceb05c 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -1,7 +1,7 @@ import Fastify from 'fastify'; import type { FastifyInstance, InjectOptions } from 'fastify'; -import app, { options } from '../custom-server/src/index.js'; +import app, { options } from '../custom-server/src/index.ts'; export const UINT16_MAX = 65535; diff --git a/test/http-api.test.ts b/test/http-api.test.ts index 92e9139..3c09610 100644 --- a/test/http-api.test.ts +++ b/test/http-api.test.ts @@ -1,11 +1,11 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import type { FastifyInstance } from 'fastify'; -import expectedComments from '../custom-server/src/data/comments.js'; -import expectedCourses from '../custom-server/src/data/courses.js'; -import expectedPosts from '../custom-server/src/data/posts.js'; -import expectedTasks from '../custom-server/src/data/tasks.js'; -import expectedUsers from '../custom-server/src/data/users.js'; +import expectedComments from '../custom-server/src/data/comments.ts'; +import expectedCourses from '../custom-server/src/data/courses.ts'; +import expectedPosts from '../custom-server/src/data/posts.ts'; +import expectedTasks from '../custom-server/src/data/tasks.ts'; +import expectedUsers from '../custom-server/src/data/users.ts'; import { buildApp, createClient, outOfRange } from './helpers.ts'; const API = '/http-api'; diff --git a/test/other-specs.test.ts b/test/other-specs.test.ts index a26622e..52b4248 100644 --- a/test/other-specs.test.ts +++ b/test/other-specs.test.ts @@ -1,8 +1,8 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import type { FastifyInstance } from 'fastify'; -import expectedPosts from '../custom-server/src/data/posts.js'; -import expectedUsers from '../custom-server/src/data/users.js'; +import expectedPosts from '../custom-server/src/data/posts.ts'; +import expectedUsers from '../custom-server/src/data/users.ts'; import { buildApp, createClient } from './helpers.ts'; // Ожидания выписаны здесь заново, а не взяты из кода: тест утверждает поведение diff --git a/test/spec-examples.test.ts b/test/spec-examples.test.ts index 6e16167..8c7e84b 100644 --- a/test/spec-examples.test.ts +++ b/test/spec-examples.test.ts @@ -1,10 +1,10 @@ import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; -import expectedComments from '../custom-server/src/data/comments.js'; -import expectedPosts from '../custom-server/src/data/posts.js'; -import expectedTasks from '../custom-server/src/data/tasks.js'; -import expectedUsers from '../custom-server/src/data/users.js'; +import expectedComments from '../custom-server/src/data/comments.ts'; +import expectedPosts from '../custom-server/src/data/posts.ts'; +import expectedTasks from '../custom-server/src/data/tasks.ts'; +import expectedUsers from '../custom-server/src/data/users.ts'; // Документацию курса читают по спецификации, а данные отдаёт приложение, то // есть примеры и набор это две копии. Сверка идёт поиском подстроки: разбирать diff --git a/tsconfig.json b/tsconfig.json index fab3175..90bd61a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,11 +1,16 @@ { "compilerOptions": { - "outDir": "./built", - "allowJs": true, "target": "ESNext", - "moduleResolution": "node", - "allowSyntheticDefaultImports": true, - "resolveJsonModule": true + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "allowImportingTsExtensions": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "types": ["node"] }, - "include": ["./server/src/**/*"] + "include": ["custom-server/src", "test", "vitest.config.ts", "openapi-ts.config.ts"] } From e3fb318258dd6678062fd698f74a902f98518352 Mon Sep 17 00:00:00 2001 From: Kirill Mokevnin Date: Wed, 14 Oct 2026 16:26:00 -0400 Subject: [PATCH 4/4] =?UTF-8?q?build:=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BD?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B8=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8?= =?UTF-8?q?=20=D1=81=20make=20=D0=BD=D0=B0=20mise,=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20=D0=B4=D0=B5=D0=BC=D0=BE=D0=BD?= =?UTF-8?q?=D1=8B,=20oxlint,=20oxfmt=20=D0=B8=20jactionlint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makefile заменён задачами и демонами в mise.toml. Спецификации в образе компилирует bin/compile.ts, mise в образ не ставится. Workflow приведены к требованиям jactionlint: экшены закреплены по SHA, заданы permissions, timeout и concurrency, у dependabot cooldown. Co-Authored-By: Claude Sonnet 5.5 --- .github/dependabot.yml | 33 +++-- .github/workflows/pr-title.yml | 11 +- .github/workflows/release.yml | 39 +++-- .oxfmtrc.json | 14 ++ .oxlintrc.json | 13 ++ AGENTS.md | 105 ++++++++----- Dockerfile | 8 +- Makefile | 53 ------- README.md | 16 +- app.config.json | 7 +- bin/compile.ts | 20 +++ custom-server/src/body-examples.ts | 38 +++-- custom-server/src/collections.ts | 21 ++- custom-server/src/data/posts.ts | 231 ++++++++++++++++++++++++----- custom-server/src/data/tasks.ts | 2 +- custom-server/src/handlers.ts | 68 ++++----- custom-server/src/index.ts | 50 ++++--- custom-server/src/rpc.ts | 6 +- custom-server/src/tasks-store.ts | 28 ++-- mise.toml | 118 ++++++++++++++- package.json | 20 +-- test/collections.test.ts | 9 +- test/helpers.ts | 21 ++- test/http-api.test.ts | 57 ++++--- test/other-specs.test.ts | 27 +++- tspconfig.yaml | 2 +- 26 files changed, 711 insertions(+), 306 deletions(-) create mode 100644 .oxfmtrc.json create mode 100644 .oxlintrc.json delete mode 100644 Makefile create mode 100644 bin/compile.ts diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 46e9c62..aad4ea7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,31 +1,40 @@ version: 2 updates: - - package-ecosystem: "npm" - directory: "/" + - package-ecosystem: 'npm' + directory: '/' schedule: - interval: "monthly" + interval: 'monthly' open-pull-requests-limit: 1 + # Свежий релиз неделю вылёживается: так не попасть на отозванную версию. + cooldown: + default-days: 7 groups: all-npm-dependencies: patterns: - - "*" + - '*' - - package-ecosystem: "docker" - directory: "/" + - package-ecosystem: 'docker' + directory: '/' schedule: - interval: "monthly" + interval: 'monthly' open-pull-requests-limit: 1 + # Свежий релиз неделю вылёживается: так не попасть на отозванную версию. + cooldown: + default-days: 7 groups: all-docker-dependencies: patterns: - - "*" + - '*' - - package-ecosystem: "github-actions" - directory: "/" + - package-ecosystem: 'github-actions' + directory: '/' schedule: - interval: "monthly" + interval: 'monthly' open-pull-requests-limit: 1 + # Свежий релиз неделю вылёживается: так не попасть на отозванную версию. + cooldown: + default-days: 7 groups: all-github-actions-dependencies: patterns: - - "*" + - '*' diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml index 22c4d6b..9e17019 100644 --- a/.github/workflows/pr-title.yml +++ b/.github/workflows/pr-title.yml @@ -5,7 +5,9 @@ name: PR Title # поднять. Проверка отдельных коммитов ветки намеренно не делается, чтобы не # мешать работе внутри PR. on: - pull_request_target: + # pull_request_target нужен намеренно: экшену надо читать заголовок и у PR из + # форков. Код PR здесь не чекаутится и не выполняется, токен только на чтение. + pull_request_target: # jactionlint ignore=dangerous-triggers types: - opened - edited @@ -15,10 +17,15 @@ on: permissions: pull-requests: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + jobs: lint: runs-on: ubuntu-latest + timeout-minutes: 5 steps: - - uses: amannn/action-semantic-pull-request@v6 + - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 522e90d..4498082 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,39 +13,60 @@ on: branches: - main +permissions: + contents: read + +# Прогон на пуллреквесте отменяется новым коммитом, а прогон на main нет: +# отменённая публикация образа оставила бы main без свежего образа. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: build: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - uses: jdx/mise-action@08ae2a091c038e1e05ec6f5115dc5c69d5a2a6b8 # v5 - - uses: jdx/mise-action@v5 - name: Install - run: make setup + run: mise run setup - - name: Tests - run: make test + - name: Check + run: mise run check deploy: needs: build runs-on: ubuntu-latest + timeout-minutes: 30 if: ${{ github.event_name == 'push' }} + permissions: + contents: read + packages: write steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - - uses: docker/setup-buildx-action@v4 + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4 + with: + cache-binary: false - name: Log in to GitHub Container Registry - uses: docker/login-action@v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push - uses: docker/build-push-action@v7 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7 with: context: . push: true diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000..7463163 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,14 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "singleQuote": true, + "semi": true, + "printWidth": 120, + "ignorePatterns": [ + "custom-server/src/generated", + "custom-server/assets", + "tsp-output", + "pnpm-lock.yaml", + "**/*.tsp", + "**/*.md" + ] +} diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..28e6942 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,13 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript", "import", "node", "promise", "vitest", "unicorn", "oxc"], + "categories": { + "correctness": "error", + "suspicious": "warn" + }, + "env": { "node": true, "es2026": true }, + "rules": { + "oxc/no-async-endpoint-handlers": "off" + }, + "ignorePatterns": ["custom-server/src/generated", "custom-server/assets", "tsp-output", "node_modules"] +} diff --git a/AGENTS.md b/AGENTS.md index 66f0741..04bd24c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ HTTP API, протокол HTTP, Postman, js-playwright. Уроки и само ## Как собран сервер Четыре спецификации на TypeSpec (`typespec//`) компилируются в OpenAPI -(`make compile` → `tsp-output/`), по одной на курс: `http-api`, `http-protocol`, +(`mise run compile` → `tsp-output/`), по одной на курс: `http-api`, `http-protocol`, `js-playwright`, `postman`. Все четыре целиком обслуживает приложение на fastify (`custom-server/`). Мока prism больше нет ни у одной: он возвращал пример дословно и запрос не разбирал, а уроки учат как раз на `skip`, `limit`, `select` @@ -22,7 +22,7 @@ HTTP API, протокол HTTP, Postman, js-playwright. Уроки и само руками**. Их строит из той же спецификации `fastify-openapi-glue` (`custom-server/src/openapi-spec.ts`, плагин регистрируется по разу на каждую из четырёх), а типы обработчиков и моделей генерирует -`@hey-api/openapi-ts` (`make generate` → `custom-server/src/generated/`). +`@hey-api/openapi-ts` (`mise run generate` → `custom-server/src/generated/`). Написанного руками остаётся ровно столько, сколько в OpenAPI не выражается: сами данные, выборка страницы и полей, `/http-api/rpc`, `/http-api/echo`, `/http-protocol/login`, стрим и куки для курса про протокол, статика. @@ -89,8 +89,8 @@ Caddy :$PORT операции в спецификации переименовывает ключ в `handlers.ts`, и это видно проверкой типов. * **Сгенерированное лежит в репозитории.** `custom-server/src/generated/` - коммитится, образ генератор не тянет. Свежесть проверяет `make check-generated` - внутри `make test`: он перегенерирует и падает на непустом диффе. + коммитится, образ генератор не тянет. Свежесть проверяет `mise run check-generated` + внутри `mise run check`: он перегенерирует и падает на непустом диффе. * **`--options` у fastify-cli обязателен.** Через него приложение отдаёт настройку ajv. Без флага ajv не знает формата `uint16` из спецификации и сборка схемы падает на старте, причём молча: код 1 и ни строки вывода. @@ -100,7 +100,7 @@ Caddy :$PORT отрицательного числа отдаст хвост списка вместо ошибки. * **Схема ответа снимается у операций с `select`.** OpenAPI не умеет сказать «набор полей ответа зависит от параметра запроса», поэтому сериализатор падает - `"lastName" is required!` на верном ответе. Хук `onRoute` в `http-api.ts` + `"lastName" is required!` на верном ответе. Хук `onRoute` в `openapi-spec.ts` убирает `schema.response` там, где у операции есть `select`; валидация запроса остаётся. * **405 даёт `fastify-allow`, а не спецификация.** Плагин регистрируется в @@ -108,7 +108,7 @@ Caddy :$PORT обрабатывается корневым контекстом fastify, куда хуки плагина не достают, и неверный метод отвечает 404 вместо 405. * **Валидация отвечает 400, уроки учат на 422.** Отображение делает - `setErrorHandler` в `http-api.ts` по коду `FST_ERR_VALIDATION`. + `setErrorHandler` в `openapi-spec.ts` по коду `FST_ERR_VALIDATION`. **Дефолты `skip` и `limit` в спецификации несут пагинацию.** `skip: uint16 = 0` и `limit: uint16 = 30` выглядят необязательными, но значение по умолчанию подставляет валидатор, и без них пагинация ломается **частично**: `skip` продолжает работать, `limit` перестаёт. Снаружи это читается как «limit не поддерживается», хотя причина в правке спецификации. @@ -126,11 +126,11 @@ Caddy :$PORT `courses/ru/http_api_course`. Набор задач лежит **в двух местах**: данными в -`custom-server/src/data/tasks.js`, откуда их берут и REST, и RPC через общий -`tasks-store.js`, и примерами в `typespec/http-api/models/task.tsp`, откуда их +`custom-server/src/data/tasks.ts`, откуда их берут и REST, и RPC через общий +`tasks-store.ts`, и примерами в `typespec/http-api/models/task.tsp`, откуда их берёт документация. Поведение задают данные, примеры нужны для Swagger UI, и совпадать они обязаны: иначе документация расходится с ответами. Расхождение -валит `make test`. +валит `mise run test`. ## Коды ответов несут уроки @@ -139,10 +139,10 @@ Caddy :$PORT | Запрос | Код | Кто отдаёт | | --- | --- | --- | -| `GET /nosuch` | 404 | fastify, обработчик в `index.js` | +| `GET /nosuch` | 404 | fastify, обработчик в `index.ts` | | `DELETE /tasks` | 405 | `fastify-allow` | | `POST /tasks` с `{}` | 422 | схема из спецификации плюс `setErrorHandler` | -| `?skip=-1` | 422 | формат `uint16`, объявленный в `index.js` | +| `?skip=-1` | 422 | формат `uint16`, объявленный в `index.ts` | | `POST /posts` без токена | 401 | `securityHandlers` по `@useAuth` | | `GET /courses` без ключа | 401 | `securityHandlers` по `@useAuth` | | `GET /postman/tasks/1` без Basic | 401 | `securityHandlers` по `@useAuth` | @@ -155,7 +155,7 @@ Caddy :$PORT и его роняет перенос регистрации внутрь вложенного контекста. Коды 201, 204 и 404 записи ставит обработчик. -Проверку всех кодов держит `make test`. +Проверку всех кодов держит `mise run test`. **Трейлинг-слеш маршруты не прощают:** `/postman/users/` отдаёт 404. В текстах курсов адрес пишется без слеша. @@ -173,25 +173,64 @@ Caddy :$PORT ## Прогон -`make test` сначала перегенерирует типы и падает, если сгенерированное в -репозитории отстало от спецификации, затем поднимает приложение и проверяет -ответы запросами по всем четырём префиксам: пагинацию, `select`, отбор по пути, -вложенные ресурсы, состав коллекций, схемы авторизации, коды из таблицы выше, -границы `uint16`, совпадение данных REST и RPC и совпадение примеров -спецификации с наборами. Нужна скомпилированная спецификация, то есть сначала -`make setup` либо `make compile`. - -Устаревший `tsp-output/` виден именно здесь: `check-generated` перегенерирует -типы из него и падает на `git diff`, хотя спецификации в репозитории не менялись. -Лечится `make compile`. - -**`make generate` очищает `custom-server/src/generated/` перед записью.** Неудачная проба сносит закоммиченное, поэтому сгенерированное восстанавливается из git, а не переписывается руками. - -Сервисы в тесте заводятся `detached: true` и снимаются группой (`process.kill(-pid)`): без этого внук `pnpm` переживает `SIGTERM` обёртке и держит трубы открытыми. Трубу обязательно вычитывать. Процессы снимаются по PID, потому что `pkill -f "fastify start"` совпадает с текстом собственной команды и убивает свою же оболочку. Пустой вывод **у всех** сервисов сразу означает нехватку ресурсов машины, а не поломку кода. - -Caddy в прогоне не участвует, в раннере его нет: порты опрашиваются напрямую, как -это делает Caddy со срезанным префиксом. Сборка гоняется и на пуллреквестах, -публикация образа — только с `main`. +`mise run check` гоняет всё, что гоняет CI: `typecheck` (tsc), `lint` (oxlint), +`fmt:check` (oxfmt), `lint:actions` (jactionlint по `.github/`), +`check-generated` и `test`. Задачи объявлены в `mise.toml`, зависимости между +ними (`compile` → `generate` → `check-generated`, `compile` → `test`) тоже: +отдельно ничего поднимать не нужно. + +`test` это vitest (`test/*.test.ts`). Приложение собирается в процессе и +опрашивается через `app.inject()`, поэтому ни порта, ни дочерних процессов, ни +Caddy в прогоне нет. Проверяются все четыре префикса: пагинация, `select`, отбор +по пути, вложенные ресурсы, состав коллекций, схемы авторизации, коды из таблицы +выше, границы `uint16`, совпадение данных REST и RPC, совпадение примеров +спецификации с наборами, форматы тела, страницы документации. + +* **Экземпляр собирается с `options`.** `test/helpers.ts` делает + `Fastify(options)`, как `fastify start --options`. Голый `Fastify()` не знает + формата `uint16`, и сборка схемы падает на старте. +* **Корень vitest это корень репозитория.** glue и документация читают + `tsp-output/...` по относительному пути, поэтому `test` зависит от `compile`. + Устаревший `tsp-output/` виден в `check-generated`: он перегенерирует типы из + него и падает на `git diff`, хотя спецификации в репозитории не менялись. + Лечится `mise run compile`. +* **`mise run generate` очищает `custom-server/src/generated/` перед записью.** + Неудачная проба сносит закоммиченное, поэтому сгенерированное восстанавливается + из git, а не переписывается руками. +* **Тест на арность плагина не удалять.** `fastify-cli` отвергает async-плагин с + одним аргументом (`_options` в `index.ts` нужен не ради значения), и приложение + тогда не стартует: код 1 и сообщение только в консоли `fastify start`, которого + `app.inject()` не видит. + +## TypeScript + +Исходники это `.ts`, сборки нет: Node 26 сам снимает типы. Отсюда ограничения. + +* Только стираемый синтаксис (`erasableSyntaxOnly`): без `enum`, `namespace` и + параметров-свойств конструктора. +* Относительные импорты пишутся с расширением `.ts`. +* Типы проверяет `tsc --noEmit` (`mise run typecheck`), сам он ничего не пишет. +* `handlers.ts` сверяет ключи с `RouteHandlers` через `satisfies`: переименованная + в спецификации операция видна ошибкой типов. Сигнатуры обработчиков свободные, + потому что `Reply` в сгенерированных типах это карта статусов, а не тело. +* `@hey-api/openapi-ts` вызывается с `importFileExtension: '.ts'`, иначе + сгенерированное не проходит `nodenext`. + +Линтер oxlint (`.oxlintrc.json`) и форматтер oxfmt (`.oxfmtrc.json`). Правило +`oxc/no-async-endpoint-handlers` выключено: оно написано для Express, а fastify +сам обрабатывает отклонённые промисы. Markdown форматтер не трогает. + +## Стенд на хосте + +`mise daemons start` поднимает приложение (`:4010`) и Caddy перед ним (`:8080`), +`mise daemons stop` снимает. Демоны экспериментальные, флаг `experimental` лежит +в `mise.toml`. В образ mise не ставится: там то же делает `bin/start.sh`, а +`bin/compile.ts` компилирует спецификации и на хосте, и в образе. Порт Caddy в +`ready_port` записан числом 8080: с другим `PORT` демон будет считаться +неготовым. + +Сборка гоняется и на пуллреквестах, публикация образа только с `main`. Экшены в +workflow закреплены по SHA, это требует jactionlint. ## Деплой @@ -236,9 +275,9 @@ make deploy APP=http_example HOST=timeweb SKIP=caddy,cron и удаление только сообщают, что произошло бы. Сервер учебный, запросы к нему идут одновременно от множества студентов, и мутации сделали бы уроки невоспроизводимыми: самостоятельная описывает один набор, а следующий студент -получил бы другой. Прогон `make test` проверяет, что после `POST` набор задач +получил бы другой. Прогон `mise run test` проверяет, что после `POST` набор задач остался прежним. ## Команды -Остальное смотрится в [Makefile](./Makefile). +Список задач: `mise tasks`, описания в [mise.toml](./mise.toml). diff --git a/Dockerfile b/Dockerfile index 23eae47..00886ef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ WORKDIR /app ENV NODE_ENV=production -RUN apk add --no-cache make caddy +RUN apk add --no-cache caddy # corepack из образов Node 26 убран, поэтому pnpm ставится напрямую. Версия # совпадает с полем packageManager, чтобы образ и разработка не расходились. @@ -17,8 +17,10 @@ RUN pnpm install --frozen-lockfile && pnpm store prune COPY . . -RUN make compile +# Спецификации компилирует тот же скрипт, что и `mise run compile`. mise в образ +# не ставится: демонов на хосте здесь заменяет bin/start.sh. +RUN node bin/compile.ts -CMD ["make", "start"] +CMD ["./bin/start.sh"] EXPOSE 8080 diff --git a/Makefile b/Makefile deleted file mode 100644 index 6fd14c0..0000000 --- a/Makefile +++ /dev/null @@ -1,53 +0,0 @@ -IMAGE_ID := ghcr.io/hexlet-components/http-example -PORT ?= 8080 - -setup: - pnpm install --frozen-lockfile - make compile - -# Типы обработчиков из спецификации. Сгенерированное лежит в репозитории, а не -# собирается на старте: образ не должен тянуть генератор, а свежесть проверяет -# `make check-generated` в прогоне. -generate: - pnpm exec openapi-ts - -check-generated: - pnpm exec openapi-ts - git diff --exit-code custom-server/src/generated - -compile: - pnpm exec tsp compile ./typespec/http-api/main.tsp --output-dir "./tsp-output/http-api" - pnpm exec tsp compile ./typespec/postman/main.tsp --output-dir "./tsp-output/postman" - pnpm exec tsp compile ./typespec/http-protocol/main.tsp --output-dir "./tsp-output/http-protocol" - pnpm exec tsp compile ./typespec/js-playwright/main.tsp --output-dir "./tsp-output/js-playwright" - -start: - ./bin/start.sh - -test: - make check-generated - pnpm exec vitest run - -update-deps: - pnpm exec ncu -u - -compose-build: - docker compose build - -compose-bash: - docker compose run --rm app sh - -compose-setup: - docker compose run --rm app setup - -compose: - docker compose up - -compose-down: - docker compose down - -compose-logs: - docker compose logs -f --tail=200 - -compose-ps: - docker compose ps diff --git a/README.md b/README.md index 844807f..0e9a591 100644 --- a/README.md +++ b/README.md @@ -21,19 +21,21 @@ ## Prerequisites -- Make -- Docker +- [mise](https://mise.jdx.dev) (ставит Node, pnpm, Caddy и jactionlint по `mise.toml`) +- Docker (только для `compose:*`) ## Commands ```bash -make setup # зависимости и компиляция спецификаций -make test # прогон против всех четырёх префиксов -make compose-build -make compose # http://localhost:8080 +mise run setup # зависимости и компиляция спецификаций +mise run check # типы, линтеры, сгенерированное, тесты +mise run test # только тесты, против всех четырёх префиксов +mise daemons start # приложение и Caddy на http://localhost:8080 +mise run compose:build +mise run compose:up # http://localhost:8080 ``` -Остальные цели — в [Makefile](./Makefile). +Остальные задачи: `mise tasks`. --- diff --git a/app.config.json b/app.config.json index 0d81d06..2ebae64 100644 --- a/app.config.json +++ b/app.config.json @@ -1,9 +1,4 @@ { "docRoute": "openapi", - "apps": [ - "http-api", - "http-protocol", - "js-playwright", - "postman" - ] + "apps": ["http-api", "http-protocol", "js-playwright", "postman"] } diff --git a/bin/compile.ts b/bin/compile.ts new file mode 100644 index 0000000..b55cd91 --- /dev/null +++ b/bin/compile.ts @@ -0,0 +1,20 @@ +// Компилирует спецификации TypeSpec в OpenAPI, по одной на приложение из +// app.config.json. Список приложений берётся оттуда же, откуда его читает +// сервер, поэтому новое приложение не приходится прописывать в двух местах. +// +// Отдельный скрипт, а не задача mise: его зовёт и образ, а mise в образ не +// ставится. +import { spawnSync } from 'node:child_process'; + +import appConfig from '../app.config.json' with { type: 'json' }; + +for (const name of appConfig.apps) { + const { status } = spawnSync( + 'pnpm', + ['exec', 'tsp', 'compile', `./typespec/${name}/main.tsp`, '--output-dir', `./tsp-output/${name}`], + { stdio: 'inherit' }, + ); + if (status !== 0) { + process.exit(status ?? 1); + } +} diff --git a/custom-server/src/body-examples.ts b/custom-server/src/body-examples.ts index 1ba843e..098d9c4 100644 --- a/custom-server/src/body-examples.ts +++ b/custom-server/src/body-examples.ts @@ -1,5 +1,5 @@ -import multipart from "@fastify/multipart"; -import type { FastifyInstance } from "fastify"; +import multipart from '@fastify/multipart'; +import type { FastifyInstance } from 'fastify'; // Изолированный учебный обработчик отражает данные без изменения коллекций. export default async function setUpBodyExamples(app: FastifyInstance) { @@ -7,32 +7,38 @@ export default async function setUpBodyExamples(app: FastifyInstance) { limits: { files: 1, fields: 10, parts: 11, fileSize: 65536, fieldSize: 4096 }, }); - app.get("/http-protocol/form", (_req, res) => res.sendFile("http-protocol/form.html")); - app.post("/http-protocol/form", (req, res) => { - const { title = "", description = "" } = (req.body ?? {}) as Record; - return res - .type("text/plain; charset=utf-8") - .send(`title=${title}\ndescription=${description}\n`); + app.get('/http-protocol/form', (_req, res) => res.sendFile('http-protocol/form.html')); + app.post('/http-protocol/form', (req, res) => { + const { title = '', description = '' } = (req.body ?? {}) as Record; + return res.type('text/plain; charset=utf-8').send(`title=${title}\ndescription=${description}\n`); }); - app.get("/http-protocol/image.png", (_req, res) => res.sendFile("http-protocol/image.png")); + app.get('/http-protocol/image.png', (_req, res) => res.sendFile('http-protocol/image.png')); - app.post("/http-protocol/body", async (req) => { - const headers: Record = { "Content-Type": req.headers["content-type"] }; + app.post('/http-protocol/body', async (req) => { + const headers: Record = { 'Content-Type': req.headers['content-type'] }; if (req.headers.accept) headers.Accept = req.headers.accept; - const result: { headers: typeof headers; json: unknown; form: Record; files: Record } = { - headers, json: null, form: Object.create(null), files: Object.create(null), + const result: { + headers: typeof headers; + json: unknown; + form: Record; + files: Record; + } = { + headers, + json: null, + form: Object.create(null), + files: Object.create(null), }; if (req.isMultipart()) { for await (const part of req.parts()) { - if (part.type === "file") { + if (part.type === 'file') { const data = await part.toBuffer(); - result.files[part.fieldname] = data.toString("utf8"); + result.files[part.fieldname] = data.toString('utf8'); } else { result.form[part.fieldname] = (part as { value: unknown }).value; } } - } else if (req.headers["content-type"]?.startsWith("application/json")) { + } else if (req.headers['content-type']?.startsWith('application/json')) { result.json = req.body; } else { result.form = (req.body ?? {}) as Record; diff --git a/custom-server/src/collections.ts b/custom-server/src/collections.ts index 61b68d7..61b618b 100644 --- a/custom-server/src/collections.ts +++ b/custom-server/src/collections.ts @@ -7,7 +7,7 @@ // (FEEDBACK-166, FEEDBACK-371). // // Здесь только разбор параметров и выборка. Маршруты и коды ответов лежат в -// routes.js, данные в data/, а один и тот же список задач берёт отсюда же и +// handlers.ts, данные в data/, а один и тот же список задач берёт отсюда же и // JSON-RPC, чтобы REST и RPC не расходились. export const DEFAULT_LIMIT = 30; @@ -56,15 +56,17 @@ export const parseSelect = (select?: string | string[] | null): string[] | null export const project = (item: T, fields: string[] | null): Partial => { if (fields === null) return item; const wanted = new Set(fields); - return Object.fromEntries( - Object.entries(item).filter(([key]) => key === 'id' || wanted.has(key)), - ) as Partial; + return Object.fromEntries(Object.entries(item).filter(([key]) => key === 'id' || wanted.has(key))) as Partial; }; // total это размер всего набора, а не выданной страницы: по нему клиент понимает, // есть ли записи за пределами limit. export const page = ({ - items, envelope, skip, limit, fields = null, + items, + envelope, + skip, + limit, + fields = null, }: { items: T[]; envelope: string; @@ -78,7 +80,8 @@ export const page = ({ limit, }); -export const findById = (items: T[], id: Param): T | undefined => items.find((item) => item.id === Number(id)); +export const findById = (items: T[], id: Param): T | undefined => + items.find((item) => item.id === Number(id)); export const nextId = (items: Identified[]): number => items.reduce((max, item) => Math.max(max, item.id), 0) + 1; @@ -87,7 +90,11 @@ export const isFilled = (value: unknown): boolean => typeof value === 'string' & // Пустая строка не проходит: в спецификации у текстовых полей стоит @minLength(1). export const validateFields = ( dto: Record = {}, - { required = [], optional = [], partial = false }: { required?: string[]; optional?: string[]; partial?: boolean } = {}, + { + required = [], + optional = [], + partial = false, + }: { required?: string[]; optional?: string[]; partial?: boolean } = {}, ): string[] => { const problems: string[] = []; diff --git a/custom-server/src/data/posts.ts b/custom-server/src/data/posts.ts index 440ff69..1929cbd 100644 --- a/custom-server/src/data/posts.ts +++ b/custom-server/src/data/posts.ts @@ -11,44 +11,209 @@ // вложенный ресурс `/users/1/posts`, и на пустом или однозаписочном списке он // ничего не объясняет. export default [ - { id: 1, authorId: 1, title: 'How HTTP works', body: 'Taking a request and a response apart: request line, headers, body' }, - { id: 2, authorId: 1, title: 'Status codes in practice', body: 'How 401 differs from 403 and why 404 shows up more often than the rest' }, + { + id: 1, + authorId: 1, + title: 'How HTTP works', + body: 'Taking a request and a response apart: request line, headers, body', + }, + { + id: 2, + authorId: 1, + title: 'Status codes in practice', + body: 'How 401 differs from 403 and why 404 shows up more often than the rest', + }, { id: 3, authorId: 1, title: 'REST and RPC', body: 'The same list of tasks served in two different ways' }, - { id: 4, authorId: 2, title: 'Headers people forget', body: 'Content-Type, Accept and why the server answers with something else' }, - { id: 5, authorId: 3, title: 'Idempotency in plain words', body: 'Why a repeated PUT is safe and a repeated POST is not' }, - { id: 6, authorId: 1, title: 'Pagination with skip and limit', body: 'Serving long lists in parts and why the response carries total' }, - { id: 7, authorId: 4, title: 'Caching responses', body: 'ETag, Last-Modified and conditional requests on a live example' }, - { id: 8, authorId: 2, title: 'Authentication versus authorization', body: 'Who you are and what you may do: two problems, two mechanisms' }, - { id: 9, authorId: 5, title: 'A bearer token from the inside', body: 'Where the token comes from, where it lives and what to do when it expires' }, - { id: 10, authorId: 1, title: 'JWT is signed, not encrypted', body: 'Anyone can read the payload, the signature is what stops forgery' }, - { id: 11, authorId: 6, title: 'OAuth without magic', body: 'Trading the code for a token step by step, from both sides' }, - { id: 12, authorId: 3, title: 'API versions: path or header', body: 'Where to put the version and what it costs the clients' }, - { id: 13, authorId: 7, title: 'OpenAPI as a contract', body: 'Specification before code and what both sides get out of it' }, - { id: 14, authorId: 2, title: 'Validation at the boundary', body: 'Why 422 is more useful than a handler that crashed' }, - { id: 15, authorId: 8, title: 'Errors in the response body', body: 'A problem document instead of a bare status code: RFC 7807 in practice' }, - { id: 16, authorId: 1, title: 'CORS: whose request is this', body: 'The preflight request, the headers and the usual frontend traps' }, - { id: 17, authorId: 4, title: 'Uploading files through an API', body: 'Multipart against handing the client a direct upload URL' }, - { id: 18, authorId: 9, title: 'Rate limiting', body: 'Windows, quotas and the headers that tell the client where the limit is' }, - { id: 19, authorId: 5, title: 'Webhooks instead of polling', body: 'How the server reports an event and what to do about duplicates' }, + { + id: 4, + authorId: 2, + title: 'Headers people forget', + body: 'Content-Type, Accept and why the server answers with something else', + }, + { + id: 5, + authorId: 3, + title: 'Idempotency in plain words', + body: 'Why a repeated PUT is safe and a repeated POST is not', + }, + { + id: 6, + authorId: 1, + title: 'Pagination with skip and limit', + body: 'Serving long lists in parts and why the response carries total', + }, + { + id: 7, + authorId: 4, + title: 'Caching responses', + body: 'ETag, Last-Modified and conditional requests on a live example', + }, + { + id: 8, + authorId: 2, + title: 'Authentication versus authorization', + body: 'Who you are and what you may do: two problems, two mechanisms', + }, + { + id: 9, + authorId: 5, + title: 'A bearer token from the inside', + body: 'Where the token comes from, where it lives and what to do when it expires', + }, + { + id: 10, + authorId: 1, + title: 'JWT is signed, not encrypted', + body: 'Anyone can read the payload, the signature is what stops forgery', + }, + { + id: 11, + authorId: 6, + title: 'OAuth without magic', + body: 'Trading the code for a token step by step, from both sides', + }, + { + id: 12, + authorId: 3, + title: 'API versions: path or header', + body: 'Where to put the version and what it costs the clients', + }, + { + id: 13, + authorId: 7, + title: 'OpenAPI as a contract', + body: 'Specification before code and what both sides get out of it', + }, + { + id: 14, + authorId: 2, + title: 'Validation at the boundary', + body: 'Why 422 is more useful than a handler that crashed', + }, + { + id: 15, + authorId: 8, + title: 'Errors in the response body', + body: 'A problem document instead of a bare status code: RFC 7807 in practice', + }, + { + id: 16, + authorId: 1, + title: 'CORS: whose request is this', + body: 'The preflight request, the headers and the usual frontend traps', + }, + { + id: 17, + authorId: 4, + title: 'Uploading files through an API', + body: 'Multipart against handing the client a direct upload URL', + }, + { + id: 18, + authorId: 9, + title: 'Rate limiting', + body: 'Windows, quotas and the headers that tell the client where the limit is', + }, + { + id: 19, + authorId: 5, + title: 'Webhooks instead of polling', + body: 'How the server reports an event and what to do about duplicates', + }, { id: 20, authorId: 2, title: 'Soft deletes', body: 'When a record has to be hidden rather than lost' }, - { id: 21, authorId: 10, title: 'Filtering and sorting lists', body: 'Conventions for query parameters that do not turn into a mess' }, + { + id: 21, + authorId: 10, + title: 'Filtering and sorting lists', + body: 'Conventions for query parameters that do not turn into a mess', + }, { id: 22, authorId: 3, title: 'Partial updates', body: 'PATCH and why it is not a PUT with half of the fields' }, { id: 23, authorId: 6, title: 'Nested resources', body: 'When /users/1/posts reads better than a filter by author' }, - { id: 24, authorId: 1, title: 'Choosing response fields', body: 'The select parameter and saving on data nobody asked for' }, - { id: 25, authorId: 7, title: 'HTTP/2 and many requests', body: 'What changed for the client and why bundling assets is history' }, - { id: 26, authorId: 4, title: 'Timeouts and retries', body: 'A client that does not wait forever and a server that expects retries' }, - { id: 27, authorId: 8, title: 'Request logs without the noise', body: 'What to write to be able to debug and what never to write at all' }, - { id: 28, authorId: 5, title: 'Testing an API', body: 'Contract checks, mocks and why unit tests alone are not enough' }, - { id: 29, authorId: 9, title: 'Documentation people actually use', body: 'Example requests matter more than field descriptions' }, + { + id: 24, + authorId: 1, + title: 'Choosing response fields', + body: 'The select parameter and saving on data nobody asked for', + }, + { + id: 25, + authorId: 7, + title: 'HTTP/2 and many requests', + body: 'What changed for the client and why bundling assets is history', + }, + { + id: 26, + authorId: 4, + title: 'Timeouts and retries', + body: 'A client that does not wait forever and a server that expects retries', + }, + { + id: 27, + authorId: 8, + title: 'Request logs without the noise', + body: 'What to write to be able to debug and what never to write at all', + }, + { + id: 28, + authorId: 5, + title: 'Testing an API', + body: 'Contract checks, mocks and why unit tests alone are not enough', + }, + { + id: 29, + authorId: 9, + title: 'Documentation people actually use', + body: 'Example requests matter more than field descriptions', + }, { id: 30, authorId: 2, title: 'Cursor pagination', body: 'When skip stops working and what replaces it' }, - { id: 31, authorId: 10, title: 'Bulk operations', body: 'One request for many records and what to answer on partial success' }, - { id: 32, authorId: 3, title: 'Long running operations', body: 'Answering 202, handing out a status URL and polling for the result' }, - { id: 33, authorId: 6, title: 'Date format in responses', body: 'ISO 8601, time zones and arguments that can be avoided' }, - { id: 34, authorId: 7, title: 'Numbers and money', body: 'Why an amount should not travel as a floating point number' }, - { id: 35, authorId: 1, title: 'Staying compatible while changing', body: 'What can be added safely and what breaks existing clients' }, - { id: 36, authorId: 4, title: 'GraphQL next to REST', body: 'Where a schema query wins and where plain endpoints do' }, - { id: 37, authorId: 8, title: 'Request size limits', body: 'Status 413, sensible limits and messages that explain them' }, + { + id: 31, + authorId: 10, + title: 'Bulk operations', + body: 'One request for many records and what to answer on partial success', + }, + { + id: 32, + authorId: 3, + title: 'Long running operations', + body: 'Answering 202, handing out a status URL and polling for the result', + }, + { + id: 33, + authorId: 6, + title: 'Date format in responses', + body: 'ISO 8601, time zones and arguments that can be avoided', + }, + { + id: 34, + authorId: 7, + title: 'Numbers and money', + body: 'Why an amount should not travel as a floating point number', + }, + { + id: 35, + authorId: 1, + title: 'Staying compatible while changing', + body: 'What can be added safely and what breaks existing clients', + }, + { + id: 36, + authorId: 4, + title: 'GraphQL next to REST', + body: 'Where a schema query wins and where plain endpoints do', + }, + { + id: 37, + authorId: 8, + title: 'Request size limits', + body: 'Status 413, sensible limits and messages that explain them', + }, { id: 38, authorId: 5, title: 'Service health', body: 'Readiness and liveness checks and how they differ' }, - { id: 39, authorId: 9, title: 'Tracing a request', body: 'A request id that travels through every service on the way' }, + { + id: 39, + authorId: 9, + title: 'Tracing a request', + body: 'A request id that travels through every service on the way', + }, { id: 40, authorId: 10, title: 'Retiring an old endpoint', body: 'A warning, a deadline and the Deprecation header' }, ]; diff --git a/custom-server/src/data/tasks.ts b/custom-server/src/data/tasks.ts index d5c8077..26cc396 100644 --- a/custom-server/src/data/tasks.ts +++ b/custom-server/src/data/tasks.ts @@ -8,7 +8,7 @@ // (typespec/http-api/models/task.tsp), откуда их берёт документация. Урок kinds // курса http-api сравнивает REST и RPC на одних и тех же данных, поэтому // расхождение между этими двумя местами ломает урок. Совпадение проверяется -// прогоном bin/smoke-test.js. +// прогоном test/spec-examples.test.ts. // // Задач двадцать, и уменьшать набор нельзя: самостоятельная работа урока // example просит выполнить запрос с `skip=5` и `limit=10`. На трёх задачах он diff --git a/custom-server/src/handlers.ts b/custom-server/src/handlers.ts index d83d655..8f0a1f8 100644 --- a/custom-server/src/handlers.ts +++ b/custom-server/src/handlers.ts @@ -31,7 +31,8 @@ const TOKEN_USER_ID = 1; // Тот же токен приведён примером к модели AuthToken в спецификации и напечатан // в уроке authentication. -const TOKEN = 'r4AR4Fo0j29s9mFk4IUVA2rGTQmIrHWlioifaJLSQQYHbTXHxtSLFUVp8PANrRoAb7fgkSsbN7lt4a86pcJ07ivUpxBLyyCHaY4Pp9I7hRPphCHM7xpZ1om1'; +const TOKEN = + 'r4AR4Fo0j29s9mFk4IUVA2rGTQmIrHWlioifaJLSQQYHbTXHxtSLFUVp8PANrRoAb7fgkSsbN7lt4a86pcJ07ivUpxBLyyCHaY4Pp9I7hRPphCHM7xpZ1om1'; // Типы запроса сгенерированы по спецификации, но их Reply это карта статусов, а // не тело ответа, и под reply.send она не сходится. Поэтому сигнатуры здесь @@ -48,9 +49,8 @@ interface Authored extends Identified { authorId: number; } -const notFound = (reply: FastifyReply, id: string | number) => reply - .code(404) - .send({ code: 404, message: `Записи с идентификатором ${id} нет` }); +const notFound = (reply: FastifyReply, id: string | number) => + reply.code(404).send({ code: 404, message: `Записи с идентификатором ${id} нет` }); // Наборы данных не меняются. Сервер учебный, запросы к нему идут одновременно // от множества студентов, и мутации сделали бы уроки невоспроизводимыми: @@ -61,14 +61,17 @@ const collection = ( envelope: string, build: (dto: any) => unknown, ): Record<'list' | 'get' | 'create' | 'update' | 'remove', Handler> => ({ - list: (req, reply) => reply.send(page({ - items, - envelope, - // Значения по умолчанию подставляет валидатор по спецификации. - skip: req.query.skip as number, - limit: req.query.limit as number, - fields: parseSelect(req.query.select), - })), + list: (req, reply) => + reply.send( + page({ + items, + envelope, + // Значения по умолчанию подставляет валидатор по спецификации. + skip: req.query.skip as number, + limit: req.query.limit as number, + fields: parseSelect(req.query.select), + }), + ), get: (req, reply) => { const found = findById(items, req.params.id); @@ -93,27 +96,26 @@ const collection = ( // Вложенный ресурс отбирает записи по родителю: именно этого не умел мок, // из-за чего /users/1/posts отдавал тот же список, что /posts. -const nested = ( - parents: Identified[], - children: Authored[], - envelope: string, - foreignKey: string, -): Handler => (req, reply) => { - // Имя параметра пути берётся из спецификации: у постов автора это authorId, - // у комментариев поста postId, а не общий id. - const parentId = req.params[foreignKey]; - const parent = findById(parents, parentId); - if (!parent) return notFound(reply, parentId); - const own = children.filter((child) => (child as any)[foreignKey] === parent.id); - return reply.send(page({ - items: own, - envelope, - // Значения по умолчанию подставляет валидатор по спецификации. - skip: req.query.skip as number, - limit: req.query.limit as number, - fields: parseSelect(req.query.select), - })); -}; +const nested = + (parents: Identified[], children: Authored[], envelope: string, foreignKey: string): Handler => + (req, reply) => { + // Имя параметра пути берётся из спецификации: у постов автора это authorId, + // у комментариев поста postId, а не общий id. + const parentId = req.params[foreignKey]; + const parent = findById(parents, parentId); + if (!parent) return notFound(reply, parentId); + const own = children.filter((child) => (child as any)[foreignKey] === parent.id); + return reply.send( + page({ + items: own, + envelope, + // Значения по умолчанию подставляет валидатор по спецификации. + skip: req.query.skip as number, + limit: req.query.limit as number, + fields: parseSelect(req.query.select), + }), + ); + }; const usersRoutes = collection(users, 'users', (dto: any) => ({ id: nextId(users), diff --git a/custom-server/src/index.ts b/custom-server/src/index.ts index f74d80c..b54eed0 100644 --- a/custom-server/src/index.ts +++ b/custom-server/src/index.ts @@ -31,10 +31,10 @@ const setUpStaticAssets = (app: FastifyInstance) => { }); }; -const setupDocs = async (app: FastifyInstance) => { - const getPromises = (instance: FastifyInstance) => appConfig.apps.map(async (name) => { +const getPromises = (instance: FastifyInstance) => + appConfig.apps.map(async (name) => { const openapiFilePath = path.join(dirname, '../../tsp-output/', name, '/@typespec/openapi3/openapi.1.0.yaml'); - return await instance.register(async (innerInstance) => { + return await instance.register(async () => { await instance.register(fastifySwagger, { mode: 'static', specification: { @@ -53,13 +53,16 @@ const setupDocs = async (app: FastifyInstance) => { }, }); }); - }) - - await app.register(fp(async (instance) => { - await Promise.all(getPromises(instance)); - })); + }); +const setupDocs = async (app: FastifyInstance) => { + await app.register( + fp(async (instance) => { + await Promise.all(getPromises(instance)); + }), + ); }; + // Опции самого экземпляра fastify: fastify-cli подхватывает этот экспорт, но // только с флагом --options. Без флага ajv не узнает про формат ниже, сборка // схемы падает на старте, и падает молча: код 1 и ни строки вывода. @@ -96,10 +99,12 @@ export default async (app: FastifyInstance, _options: unknown) => { // Свой обработчик ненайденного маршрута нужен не ради текста ответа: без него // 404 обрабатывается корневым контекстом fastify, куда хуки этого плагина не // достают, и fastify-allow не успевает превратить неверный метод в 405. - app.setNotFoundHandler((req, res) => res.code(404).send({ - code: 404, - message: `Адреса ${req.url} нет`, - })); + app.setNotFoundHandler((req, res) => + res.code(404).send({ + code: 404, + message: `Адреса ${req.url} нет`, + }), + ); await app.register(formbody); await app.register(fastifyCookie, { @@ -117,7 +122,7 @@ export default async (app: FastifyInstance, _options: unknown) => { Expires: -1, 'Cache-Control': 'private, max-age=0', 'Content-Type': 'text/html; charset=ISO-8859-1', - 'P3P': 'CP="This is not a P3P policy! See g.co/p3phelp for more info."', + P3P: 'CP="This is not a P3P policy! See g.co/p3phelp for more info."', Server: 'gws', 'X-XSS-Protection': 0, 'X-Frame-Options': 'SAMEORIGIN', @@ -152,7 +157,7 @@ export default async (app: FastifyInstance, _options: unknown) => { }; // Запускаем отправку чанков - sendChunks().catch(err => { + sendChunks().catch((err) => { req.log.error(err); res.send(err); }); @@ -173,7 +178,7 @@ export default async (app: FastifyInstance, _options: unknown) => { setUpRpc(app); // REST-маршруты коллекций обслуживаются приложением, а не моком prism: см. - // шапку custom-server/src/routes.js. + // шапку custom-server/src/openapi-spec.ts. // Маршруты всех четырёх спецификаций. Каждая своим плагином, потому что внутри // свой обработчик ошибок: валидация по спецификации отвечает 400, а уроки учат // на 422. Список тот же, что задаёт маршруты документации. @@ -182,13 +187,14 @@ export default async (app: FastifyInstance, _options: unknown) => { } app.get('/postman/cookie', (req, res) => { - res.setCookie('myCookie', 'cookieValue', { - path: '/', - httpOnly: true, - secure: true, // Используйте true, если вы работаете с HTTPS - maxAge: 3600 // Время жизни куки в секундах - }) - .send('Done!'); + res + .setCookie('myCookie', 'cookieValue', { + path: '/', + httpOnly: true, + secure: true, // Используйте true, если вы работаете с HTTPS + maxAge: 3600, // Время жизни куки в секундах + }) + .send('Done!'); }); return app; diff --git a/custom-server/src/rpc.ts b/custom-server/src/rpc.ts index 3b51721..94db160 100644 --- a/custom-server/src/rpc.ts +++ b/custom-server/src/rpc.ts @@ -1,5 +1,5 @@ // JSON-RPC 2.0 endpoint for the http-api course. -// Shows the RPC style next to the REST routes in tasks-rest.js: one endpoint, +// Shows the RPC style next to the REST routes in handlers.ts: one endpoint, // always POST, errors live in the body and the status is always 200. // // Данные и операции берутся из tasks-store.js, того же модуля, из которого @@ -8,9 +8,7 @@ import type { FastifyInstance } from 'fastify'; -import { - build, find, list, parseRange, validate, -} from './tasks-store.ts'; +import { build, find, list, parseRange, validate } from './tasks-store.ts'; import type { TaskDto } from './tasks-store.ts'; // Negative codes are the protocol level ones defined by the JSON-RPC spec. diff --git a/custom-server/src/tasks-store.ts b/custom-server/src/tasks-store.ts index b2a5a1b..ea8dd82 100644 --- a/custom-server/src/tasks-store.ts +++ b/custom-server/src/tasks-store.ts @@ -1,13 +1,11 @@ // Задачи: данные и операции, общие для REST и JSON-RPC. // -// REST-маршруты собираются из этого модуля в resources.js, а JSON-RPC вызывает +// REST-маршруты собираются из этого модуля в handlers.ts, а JSON-RPC вызывает // его напрямую. Урок kinds курса http-api сравнивает два стиля и опирается на то, // что задача с одним номером в обоих одна и та же, поэтому общий модуль здесь не // украшение, а условие работоспособности урока. -import { - findById, nextId, page, parseRange, parseSelect, validateFields, -} from './collections.ts'; +import { findById, nextId, page, parseRange, parseSelect, validateFields } from './collections.ts'; import tasks from './data/tasks.ts'; const STATUSES = ['Backlog', 'Ready', 'In Progress', 'Done', 'Archived']; @@ -22,10 +20,7 @@ export interface TaskDto { export const items = () => tasks; -export const validate = ( - dto: TaskDto = {}, - options: { partial?: boolean } = {}, -): string[] => { +export const validate = (dto: TaskDto = {}, options: { partial?: boolean } = {}): string[] => { const problems = validateFields(dto as Record, { required: ['title', 'description'], ...options }); if (dto.status !== undefined && !STATUSES.includes(dto.status)) { problems.push(`status должен быть одним из: ${STATUSES.join(', ')}`); @@ -40,10 +35,17 @@ export const build = (dto: TaskDto) => ({ status: dto.status ?? 'Backlog', }); -export const list = ( - { skip = 0, limit = 30, select }: { skip?: number; limit?: number; select?: string | string[] } = {}, -) => page({ - items: tasks, envelope: 'tasks', skip, limit, fields: parseSelect(select), -}); +export const list = ({ + skip = 0, + limit = 30, + select, +}: { skip?: number; limit?: number; select?: string | string[] } = {}) => + page({ + items: tasks, + envelope: 'tasks', + skip, + limit, + fields: parseSelect(select), + }); export const find = (id: string | number | undefined) => findById(tasks, id); diff --git a/mise.toml b/mise.toml index 8a95d83..b7531d3 100644 --- a/mise.toml +++ b/mise.toml @@ -1,6 +1,118 @@ -# Тулинг репозитория для хоста и CI (jdx/mise-action). Точную версию pnpm -# задаёт packageManager в package.json: pnpm сам переключается на неё и -# записывает её в лок, поэтому здесь только мажор. +# Тулинг, задачи и демоны репозитория. Хост и CI (jdx/mise-action) читают этот +# файл. Точную версию pnpm задаёт packageManager в package.json: pnpm сам +# переключается на неё и записывает её в лок, поэтому здесь только мажор. +# +# Задачи заменили Makefile, а демоны запускают приложение с Caddy на хосте. +# В образ mise не ставится: там остаётся bin/start.sh. + +[settings] +# [daemons] пока экспериментальные. Флаг лежит в репозитории, а не только в +# глобальном конфиге, иначе `mise daemons` не заведётся на чужой машине и в CI. +experimental = true + [tools] node = "26" pnpm = "12" +caddy = "2" +jactionlint = "latest" + +[env] +# Порт Caddy, как в образе: $PORT в Caddyfile. +PORT = "{{ get_env(name='PORT', default='8080') }}" + +# --- Демоны: локальный стенд --------------------------------------------- +# `mise daemons start` поднимает приложение и Caddy перед ним, `mise daemons +# stop` снимает. В образе то же делает bin/start.sh. + +[daemons.app] +# --options обязателен, и вместе с ним сборка без `pnpm run start` не нужна: +# команда в package.json единственное место, где он прописан. +run = "exec pnpm run start" +ready_port = 4010 + +[daemons.caddy] +run = "exec caddy run" +depends = ["app"] +ready_port = 8080 + +# --- Задачи -------------------------------------------------------------- + +[tasks.compile] +description = "Скомпилировать спецификации TypeSpec в OpenAPI (tsp-output/)" +run = "node bin/compile.ts" +sources = ["typespec/**/*.tsp", "tspconfig.yaml", "app.config.json", "pnpm-lock.yaml"] +outputs = ["tsp-output/**/openapi.1.0.yaml"] + +[tasks.setup] +description = "Зависимости и компиляция спецификаций" +run = "pnpm install --frozen-lockfile" +depends_post = ["compile"] + +# Сгенерированное лежит в репозитории, а не собирается на старте: образ не +# должен тянуть генератор, а свежесть проверяет `check-generated`. +# `generate` очищает custom-server/src/generated/ перед записью, так что +# неудачная проба сносит закоммиченное: восстанавливать из git. +[tasks.generate] +description = "Типы обработчиков из спецификации (custom-server/src/generated/)" +depends = ["compile"] +run = "pnpm exec openapi-ts" + +[tasks."check-generated"] +description = "Падает, если сгенерированное в репозитории отстало от спецификации" +depends = ["generate"] +run = "git diff --exit-code custom-server/src/generated" + +[tasks.typecheck] +description = "Проверка типов (tsc --noEmit)" +run = "pnpm exec tsc" + +[tasks.lint] +description = "oxlint" +run = "pnpm exec oxlint" + +[tasks.fmt] +description = "Отформатировать код (oxfmt)" +run = "pnpm exec oxfmt" + +[tasks."fmt:check"] +description = "Проверить форматирование (oxfmt)" +run = "pnpm exec oxfmt --check" + +[tasks."lint:actions"] +description = "Проверить workflow GitHub Actions (jactionlint)" +run = "jactionlint" + +[tasks.test] +description = "Тесты vitest против всех четырёх спецификаций" +depends = ["compile"] +run = "pnpm exec vitest run" + +[tasks.check] +description = "Всё, что гоняет CI" +depends = ["typecheck", "lint", "fmt:check", "lint:actions", "check-generated", "test"] + +[tasks.start] +description = "Приложение и Caddy в одном процессе, как в образе" +run = "./bin/start.sh" + +[tasks."update-deps"] +description = "Поднять зависимости до последних версий" +run = ["pnpm exec ncu -u", "pnpm install"] + +[tasks."compose:build"] +run = "docker compose build" + +[tasks."compose:bash"] +run = "docker compose run --rm app sh" + +[tasks."compose:up"] +run = "docker compose up" + +[tasks."compose:down"] +run = "docker compose down" + +[tasks."compose:logs"] +run = "docker compose logs -f --tail=200" + +[tasks."compose:ps"] +run = "docker compose ps" diff --git a/package.json b/package.json index 5e98394..7d5f665 100644 --- a/package.json +++ b/package.json @@ -1,20 +1,11 @@ { "name": "http-server", "version": "0.1.0", + "private": true, "type": "module", "scripts": { "start": "fastify start --options -p 4010 -a 0.0.0.0 custom-server/src/index.ts" }, - "devDependencies": { - "@hey-api/openapi-ts": "^0.99.0", - "@types/node": "^26.6.5", - "npm-check-updates": "^23.1.1", - "oxfmt": "^0.73.0", - "oxlint": "^1.88.0", - "typescript": "^7.0.2", - "vitest": "^5.0.4" - }, - "private": true, "dependencies": { "@fastify/cookie": "^11.1.3", "@fastify/formbody": "^9.0.1", @@ -34,5 +25,14 @@ "fastify-openapi-glue": "^4.11.5", "fastify-plugin": "^6.0.1" }, + "devDependencies": { + "@hey-api/openapi-ts": "^0.99.0", + "@types/node": "^26.6.5", + "npm-check-updates": "^23.1.1", + "oxfmt": "^0.73.0", + "oxlint": "^1.88.0", + "typescript": "^7.0.2", + "vitest": "^5.0.4" + }, "packageManager": "pnpm@12.11.2" } diff --git a/test/collections.test.ts b/test/collections.test.ts index 0c35241..4ceeecc 100644 --- a/test/collections.test.ts +++ b/test/collections.test.ts @@ -1,8 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { - page, parseRange, parseSelect, project, -} from '../custom-server/src/collections.ts'; +import { page, parseRange, parseSelect, project } from '../custom-server/src/collections.ts'; describe('parseSelect', () => { it('пустое значение означает «все поля»', () => { @@ -45,7 +43,10 @@ describe('page', () => { it('total это размер всего набора, а не страницы', () => { const items = [{ id: 1 }, { id: 2 }, { id: 3 }]; expect(page({ items, envelope: 'items', skip: 1, limit: 1 })).toEqual({ - items: [{ id: 2 }], total: 3, skip: 1, limit: 1, + items: [{ id: 2 }], + total: 3, + skip: 1, + limit: 1, }); }); }); diff --git a/test/helpers.ts b/test/helpers.ts index 7ceb05c..0bf9f4d 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -34,15 +34,20 @@ export const createClient = (instance: FastifyInstance) => { return { status: response.statusCode, body, headers: response.headers }; }; - const json = (url: string, method: InjectOptions['method'], payload?: unknown, headers = {}) => send(url, { - method, - headers: payload === undefined ? headers : { 'content-type': 'application/json', ...headers }, - payload: payload === undefined ? undefined : JSON.stringify(payload), - }); + const json = (url: string, method: InjectOptions['method'], payload?: unknown, headers = {}) => + send(url, { + method, + headers: payload === undefined ? headers : { 'content-type': 'application/json', ...headers }, + payload: payload === undefined ? undefined : JSON.stringify(payload), + }); - const rpc = (method: string, params?: unknown) => json('/http-api/rpc', 'POST', { - jsonrpc: '2.0', method, params, id: 1, - }); + const rpc = (method: string, params?: unknown) => + json('/http-api/rpc', 'POST', { + jsonrpc: '2.0', + method, + params, + id: 1, + }); return { send, json, rpc }; }; diff --git a/test/http-api.test.ts b/test/http-api.test.ts index 3c09610..7b7e862 100644 --- a/test/http-api.test.ts +++ b/test/http-api.test.ts @@ -91,7 +91,10 @@ describe('Коллекции: пагинация, select и вложенные it('/users?skip=30 отдаёт пустую страницу с total 10', async () => { const reply = await client.send(`${USERS}?skip=30`); expect(reply.body).toEqual({ - users: [], total: expectedUsers.length, skip: 30, limit: 30, + users: [], + total: expectedUsers.length, + skip: 30, + limit: 30, }); }); @@ -103,12 +106,12 @@ describe('Коллекции: пагинация, select и вложенные it('select оставляет только запрошенные поля и id', async () => { const reply = await client.send(`${USERS}?select=firstName,email`); - expect(Object.keys(reply.body.users[0]).sort()).toEqual(['email', 'firstName', 'id']); + expect(Object.keys(reply.body.users[0]).toSorted()).toEqual(['email', 'firstName', 'id']); }); it('select работает и на одиночном ресурсе', async () => { const reply = await client.send(`${USERS}/1?select=lastName`); - expect(Object.keys(reply.body).sort()).toEqual(['id', 'lastName']); + expect(Object.keys(reply.body).toSorted()).toEqual(['id', 'lastName']); }); it('/users/1/posts отдаёт только посты автора 1 и не совпадает с /posts', async () => { @@ -129,18 +132,22 @@ describe('Коллекции: пагинация, select и вложенные it('/courses?skip=1&limit=2 применяет параметры', async () => { const reply = await client.send(`${COURSES}?skip=1&limit=2`, { headers: KEY }); expect(reply.body).toEqual({ - courses: expectedCourses.slice(1, 3), total: expectedCourses.length, skip: 1, limit: 2, + courses: expectedCourses.slice(1, 3), + total: expectedCourses.length, + skip: 1, + limit: 2, }); }); }); describe('Отсутствующая запись и авторизация по коллекциям', () => { - it.each([['users', USERS], ['posts', POSTS], ['comments', COMMENTS]])( - 'GET /%s/999 → 404', - async (_name, url) => { - expect((await client.send(`${url}/999`)).status).toBe(404); - }, - ); + it.each([ + ['users', USERS], + ['posts', POSTS], + ['comments', COMMENTS], + ])('GET /%s/999 → 404', async (_name, url) => { + expect((await client.send(`${url}/999`)).status).toBe(404); + }); // Спецификация закрывает Bearer'ом изменение постов, комментариев и // пользователей, а создание пользователя оставляет открытым. @@ -152,15 +159,19 @@ describe('Отсутствующая запись и авторизация по ] as const)('%s без токена → 401', async (_name, url, method) => { // Content-Type только вместе с телом: fastify отвечает 400 на пустое тело // при заявленном application/json, и проверка мерила бы не то. - const reply = method === 'PATCH' - ? await client.json(url, method, { title: 'x', body: 'y', firstName: 'z' }) - : await client.json(url, method); + const reply = + method === 'PATCH' + ? await client.json(url, method, { title: 'x', body: 'y', firstName: 'z' }) + : await client.json(url, method); expect(reply.status).toBe(401); }); it('POST /users без токена → 201, пароля в ответе нет', async () => { const created = await client.json(USERS, 'POST', { - email: 'john@mail.com', firstName: 'John', lastName: 'Doe', password: 'secret', + email: 'john@mail.com', + firstName: 'John', + lastName: 'Doe', + password: 'secret', }); expect(created.status).toBe(201); expect(created.body).not.toHaveProperty('password'); @@ -231,7 +242,10 @@ describe('Коды ответов, на них построены самосто it('POST /posts с токеном → 201 и authorId от сервера', async () => { const created = await client.json( - POSTS, 'POST', { title: 'title', body: 'body' }, { Authorization: 'Bearer any-value' }, + POSTS, + 'POST', + { title: 'title', body: 'body' }, + { Authorization: 'Bearer any-value' }, ); expect(created.status).toBe(201); expect(Number.isInteger(created.body.authorId)).toBe(true); @@ -256,13 +270,14 @@ describe('Коды ответов, на них построены самосто }); describe('Числа в ответах не выходят за uint16', () => { - it.each([ - [TASKS], [`${TASKS}/1`], [USERS], [`${USERS}/1`], [POSTS], [`${POSTS}/1`], [COMMENTS], - ])('%s', async (url) => { - expect(outOfRange((await client.send(url)).body, url)).toEqual([]); - }); + it.each([[TASKS], [`${TASKS}/1`], [USERS], [`${USERS}/1`], [POSTS], [`${POSTS}/1`], [COMMENTS]])( + '%s', + async (url) => { + expect(outOfRange((await client.send(url)).body, url)).toEqual([]); + }, + ); - it(COURSES, async () => { + it('/http-api/courses', async () => { expect(outOfRange((await client.send(COURSES, { headers: KEY })).body, COURSES)).toEqual([]); }); }); diff --git a/test/other-specs.test.ts b/test/other-specs.test.ts index 52b4248..01df79d 100644 --- a/test/other-specs.test.ts +++ b/test/other-specs.test.ts @@ -23,22 +23,39 @@ const SPECS: { prefix: 'http-protocol', collections: ['tasks', 'users', 'posts', 'comments'], nested: true, - guarded: [['DELETE', '/users/1', 'bearer'], ['POST', '/posts', 'bearer']], - open: [['GET', '/tasks/1'], ['POST', '/tasks']], + guarded: [ + ['DELETE', '/users/1', 'bearer'], + ['POST', '/posts', 'bearer'], + ], + open: [ + ['GET', '/tasks/1'], + ['POST', '/tasks'], + ], }, { prefix: 'js-playwright', collections: ['tasks', 'users'], nested: false, guarded: [], - open: [['GET', '/tasks/1'], ['POST', '/users'], ['DELETE', '/users/1']], + open: [ + ['GET', '/tasks/1'], + ['POST', '/users'], + ['DELETE', '/users/1'], + ], }, { prefix: 'postman', collections: ['tasks', 'users', 'posts', 'comments'], nested: true, - guarded: [['GET', '/tasks/1', 'basic'], ['POST', '/tasks', 'basic'], ['DELETE', '/users/1', 'bearer']], - open: [['GET', '/tasks'], ['POST', '/users']], + guarded: [ + ['GET', '/tasks/1', 'basic'], + ['POST', '/tasks', 'basic'], + ['DELETE', '/users/1', 'bearer'], + ], + open: [ + ['GET', '/tasks'], + ['POST', '/users'], + ], }, ]; diff --git a/tspconfig.yaml b/tspconfig.yaml index a3fe48f..22407eb 100644 --- a/tspconfig.yaml +++ b/tspconfig.yaml @@ -1,2 +1,2 @@ emit: - - "@typespec/openapi3" + - '@typespec/openapi3'